@zeix/cause-effect 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { computed, match, resolve, state, UNSET } from '../'
3
+
4
+ /* === Tests === */
5
+
6
+ describe('Match Function', () => {
7
+ test('should call ok handler for successful resolution', () => {
8
+ const a = state(10)
9
+ const b = state('hello')
10
+ let okCalled = false
11
+ let okValues: { a: number; b: string } | null = null
12
+
13
+ match(resolve({ a, b }), {
14
+ ok: values => {
15
+ okCalled = true
16
+ okValues = values
17
+ },
18
+ err: () => {
19
+ throw new Error('Should not be called')
20
+ },
21
+ nil: () => {
22
+ throw new Error('Should not be called')
23
+ },
24
+ })
25
+
26
+ expect(okCalled).toBe(true)
27
+ expect(okValues).toBeTruthy()
28
+ expect((okValues as unknown as { a: number; b: string }).a).toBe(10)
29
+ expect((okValues as unknown as { a: number; b: string }).b).toBe(
30
+ 'hello',
31
+ )
32
+ })
33
+
34
+ test('should call nil handler for pending signals', () => {
35
+ const a = state(10)
36
+ const b = state(UNSET)
37
+ let nilCalled = false
38
+
39
+ match(resolve({ a, b }), {
40
+ ok: () => {
41
+ throw new Error('Should not be called')
42
+ },
43
+ err: () => {
44
+ throw new Error('Should not be called')
45
+ },
46
+ nil: () => {
47
+ nilCalled = true
48
+ },
49
+ })
50
+
51
+ expect(nilCalled).toBe(true)
52
+ })
53
+
54
+ test('should call error handler for error signals', () => {
55
+ const a = state(10)
56
+ const b = computed(() => {
57
+ throw new Error('Test error')
58
+ })
59
+ let errCalled = false
60
+ let errValue: Error | null = null
61
+
62
+ match(resolve({ a, b }), {
63
+ ok: () => {
64
+ throw new Error('Should not be called')
65
+ },
66
+ err: errors => {
67
+ errCalled = true
68
+ errValue = errors[0]
69
+ },
70
+ nil: () => {
71
+ throw new Error('Should not be called')
72
+ },
73
+ })
74
+
75
+ expect(errCalled).toBe(true)
76
+ expect(errValue).toBeTruthy()
77
+ expect((errValue as unknown as Error).message).toBe('Test error')
78
+ })
79
+
80
+ test('should handle missing handlers gracefully', () => {
81
+ const a = state(10)
82
+ const result = resolve({ a })
83
+
84
+ // Should not throw even with no handlers
85
+ expect(() => {
86
+ match(result, {})
87
+ }).not.toThrow()
88
+ })
89
+
90
+ test('should return void always', () => {
91
+ const a = state(42)
92
+
93
+ const returnValue = match(resolve({ a }), {
94
+ ok: () => {
95
+ // Even if we try to return something, match should return void
96
+ return 'something'
97
+ },
98
+ })
99
+
100
+ expect(returnValue).toBeUndefined()
101
+ })
102
+
103
+ test('should handle handler errors by calling error handler', () => {
104
+ const a = state(10)
105
+ let handlerErrorCalled = false
106
+ let handlerError: Error | null = null
107
+
108
+ match(resolve({ a }), {
109
+ ok: () => {
110
+ throw new Error('Handler error')
111
+ },
112
+ err: errors => {
113
+ handlerErrorCalled = true
114
+ handlerError = errors[errors.length - 1] // Last error should be the handler error
115
+ },
116
+ })
117
+
118
+ expect(handlerErrorCalled).toBe(true)
119
+ expect(handlerError).toBeTruthy()
120
+ expect((handlerError as unknown as Error).message).toBe('Handler error')
121
+ })
122
+
123
+ test('should rethrow handler errors if no error handler available', () => {
124
+ const a = state(10)
125
+
126
+ expect(() => {
127
+ match(resolve({ a }), {
128
+ ok: () => {
129
+ throw new Error('Handler error')
130
+ },
131
+ })
132
+ }).toThrow('Handler error')
133
+ })
134
+
135
+ test('should combine existing errors with handler errors', () => {
136
+ const a = computed(() => {
137
+ throw new Error('Signal error')
138
+ })
139
+ let allErrors: readonly Error[] | null = null
140
+
141
+ match(resolve({ a }), {
142
+ err: errors => {
143
+ // First call with signal error
144
+ if (errors.length === 1) {
145
+ throw new Error('Handler error')
146
+ }
147
+ // Second call with both errors
148
+ allErrors = errors
149
+ },
150
+ })
151
+
152
+ expect(allErrors).toBeTruthy()
153
+ expect((allErrors as unknown as readonly Error[]).length).toBe(2)
154
+ expect((allErrors as unknown as readonly Error[])[0].message).toBe(
155
+ 'Signal error',
156
+ )
157
+ expect((allErrors as unknown as readonly Error[])[1].message).toBe(
158
+ 'Handler error',
159
+ )
160
+ })
161
+
162
+ test('should work with complex type inference', () => {
163
+ const user = state({ id: 1, name: 'Alice' })
164
+ const posts = state([{ id: 1, title: 'Hello' }])
165
+ const settings = state({ theme: 'dark' })
166
+
167
+ let typeTestPassed = false
168
+
169
+ match(resolve({ user, posts, settings }), {
170
+ ok: values => {
171
+ // TypeScript should infer these types perfectly
172
+ const userId: number = values.user.id
173
+ const userName: string = values.user.name
174
+ const firstPost = values.posts[0]
175
+ const postTitle: string = firstPost.title
176
+ const theme: string = values.settings.theme
177
+
178
+ expect(userId).toBe(1)
179
+ expect(userName).toBe('Alice')
180
+ expect(postTitle).toBe('Hello')
181
+ expect(theme).toBe('dark')
182
+ typeTestPassed = true
183
+ },
184
+ })
185
+
186
+ expect(typeTestPassed).toBe(true)
187
+ })
188
+
189
+ test('should handle side effects only pattern', () => {
190
+ const count = state(5)
191
+ const name = state('test')
192
+ let sideEffectExecuted = false
193
+ let capturedData = ''
194
+
195
+ match(resolve({ count, name }), {
196
+ ok: values => {
197
+ // Pure side effect - no return value expected
198
+ sideEffectExecuted = true
199
+ capturedData = `${values.name}: ${values.count}`
200
+ // Even if we try to return something, it should be ignored
201
+ return 'ignored'
202
+ },
203
+ })
204
+
205
+ expect(sideEffectExecuted).toBe(true)
206
+ expect(capturedData).toBe('test: 5')
207
+ })
208
+
209
+ test('should handle multiple error types correctly', () => {
210
+ const error1 = computed(() => {
211
+ throw new Error('First error')
212
+ })
213
+ const error2 = computed(() => {
214
+ throw new Error('Second error')
215
+ })
216
+ let errorMessages: string[] = []
217
+
218
+ match(resolve({ error1, error2 }), {
219
+ err: errors => {
220
+ errorMessages = errors.map(e => e.message)
221
+ },
222
+ })
223
+
224
+ expect(errorMessages).toHaveLength(2)
225
+ expect(errorMessages).toContain('First error')
226
+ expect(errorMessages).toContain('Second error')
227
+ })
228
+
229
+ test('should work with async computed signals', async () => {
230
+ const wait = (ms: number) =>
231
+ new Promise(resolve => setTimeout(resolve, ms))
232
+
233
+ const asyncSignal = computed(async () => {
234
+ await wait(10)
235
+ return 'async result'
236
+ })
237
+
238
+ // Initially should be pending
239
+ let pendingCalled = false
240
+ let okCalled = false
241
+ let finalValue = ''
242
+
243
+ let result = resolve({ asyncSignal })
244
+ match(result, {
245
+ ok: values => {
246
+ okCalled = true
247
+ finalValue = values.asyncSignal
248
+ },
249
+ nil: () => {
250
+ pendingCalled = true
251
+ },
252
+ })
253
+
254
+ expect(pendingCalled).toBe(true)
255
+ expect(okCalled).toBe(false)
256
+
257
+ // Wait for resolution
258
+ await wait(20)
259
+
260
+ result = resolve({ asyncSignal })
261
+ match(result, {
262
+ ok: values => {
263
+ okCalled = true
264
+ finalValue = values.asyncSignal
265
+ },
266
+ })
267
+
268
+ expect(okCalled).toBe(true)
269
+ expect(finalValue).toBe('async result')
270
+ })
271
+
272
+ test('should maintain referential transparency', () => {
273
+ const a = state(42)
274
+ const result = resolve({ a })
275
+ let callCount = 0
276
+
277
+ // Calling match multiple times with same result should be consistent
278
+ match(result, {
279
+ ok: () => {
280
+ callCount++
281
+ },
282
+ })
283
+
284
+ match(result, {
285
+ ok: () => {
286
+ callCount++
287
+ },
288
+ })
289
+
290
+ expect(callCount).toBe(2)
291
+ })
292
+ })
293
+
294
+ describe('Match Function Integration', () => {
295
+ test('should work seamlessly with resolve', () => {
296
+ const data = state({ id: 1, value: 'test' })
297
+ let processed = false
298
+ let processedValue = ''
299
+
300
+ match(resolve({ data }), {
301
+ ok: values => {
302
+ processed = true
303
+ processedValue = values.data.value
304
+ },
305
+ })
306
+
307
+ expect(processed).toBe(true)
308
+ expect(processedValue).toBe('test')
309
+ })
310
+
311
+ test('should handle real-world scenario with mixed states', async () => {
312
+ const wait = (ms: number) =>
313
+ new Promise(resolve => setTimeout(resolve, ms))
314
+
315
+ const syncData = state('available')
316
+ const asyncData = computed(async () => {
317
+ await wait(10)
318
+ return 'loaded'
319
+ })
320
+ const errorData = computed(() => {
321
+ throw new Error('Failed to load')
322
+ })
323
+
324
+ let pendingCount = 0
325
+ let errorCount = 0
326
+ let successCount = 0
327
+
328
+ // Should be pending initially
329
+ let result = resolve({ syncData, asyncData })
330
+ match(result, {
331
+ ok: () => successCount++,
332
+ err: () => errorCount++,
333
+ nil: () => pendingCount++,
334
+ })
335
+
336
+ expect(pendingCount).toBe(1)
337
+
338
+ // Should have errors when including error signal
339
+ result = resolve({ syncData, asyncData, errorData })
340
+ match(result, {
341
+ ok: () => successCount++,
342
+ err: () => errorCount++,
343
+ nil: () => pendingCount++,
344
+ })
345
+
346
+ expect(pendingCount).toBe(2) // Still pending due to async
347
+
348
+ // Wait for async to resolve
349
+ await wait(20)
350
+
351
+ // Should succeed with just sync and async
352
+ result = resolve({ syncData, asyncData })
353
+ match(result, {
354
+ ok: values => {
355
+ successCount++
356
+ expect(values.syncData).toBe('available')
357
+ expect(values.asyncData).toBe('loaded')
358
+ },
359
+ err: () => errorCount++,
360
+ nil: () => pendingCount++,
361
+ })
362
+
363
+ // Should error when including error signal
364
+ result = resolve({ syncData, asyncData, errorData })
365
+ match(result, {
366
+ ok: () => successCount++,
367
+ err: errors => {
368
+ errorCount++
369
+ expect(errors[0].message).toBe('Failed to load')
370
+ },
371
+ nil: () => pendingCount++,
372
+ })
373
+
374
+ expect(successCount).toBe(1)
375
+ expect(errorCount).toBe(1)
376
+ expect(pendingCount).toBe(2)
377
+ })
378
+ })
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { computed, resolve, state, UNSET } from '../'
3
+
4
+ /* === Tests === */
5
+
6
+ describe('Resolve Function', () => {
7
+ test('should return discriminated union for successful resolution', () => {
8
+ const a = state(10)
9
+ const b = state('hello')
10
+
11
+ const result = resolve({ a, b })
12
+
13
+ expect(result.ok).toBe(true)
14
+ if (result.ok) {
15
+ expect(result.values.a).toBe(10)
16
+ expect(result.values.b).toBe('hello')
17
+ expect(result.errors).toBeUndefined()
18
+ expect(result.pending).toBeUndefined()
19
+ }
20
+ })
21
+
22
+ test('should return discriminated union for pending signals', () => {
23
+ const a = state(10)
24
+ const b = state(UNSET)
25
+
26
+ const result = resolve({ a, b })
27
+
28
+ expect(result.ok).toBe(false)
29
+ expect(result.pending).toBe(true)
30
+ expect(result.values).toBeUndefined()
31
+ expect(result.errors).toBeUndefined()
32
+ })
33
+
34
+ test('should return discriminated union for error signals', () => {
35
+ const a = state(10)
36
+ const b = computed(() => {
37
+ throw new Error('Test error')
38
+ })
39
+
40
+ const result = resolve({ a, b })
41
+
42
+ expect(result.ok).toBe(false)
43
+ expect(result.pending).toBeUndefined()
44
+ expect(result.values).toBeUndefined()
45
+ expect(result.errors).toBeDefined()
46
+ if (result.errors) {
47
+ expect(result.errors[0].message).toBe('Test error')
48
+ }
49
+ })
50
+
51
+ test('should handle mixed error and valid signals', () => {
52
+ const valid = state('valid')
53
+ const error1 = computed(() => {
54
+ throw new Error('Error 1')
55
+ })
56
+ const error2 = computed(() => {
57
+ throw new Error('Error 2')
58
+ })
59
+
60
+ const result = resolve({ valid, error1, error2 })
61
+
62
+ expect(result.ok).toBe(false)
63
+ expect(result.errors).toBeDefined()
64
+ if (result.errors) {
65
+ expect(result.errors).toHaveLength(2)
66
+ expect(result.errors[0].message).toBe('Error 1')
67
+ expect(result.errors[1].message).toBe('Error 2')
68
+ }
69
+ })
70
+
71
+ test('should prioritize pending over errors', () => {
72
+ const pending = state(UNSET)
73
+ const error = computed(() => {
74
+ throw new Error('Test error')
75
+ })
76
+
77
+ const result = resolve({ pending, error })
78
+
79
+ expect(result.ok).toBe(false)
80
+ expect(result.pending).toBe(true)
81
+ expect(result.errors).toBeUndefined()
82
+ })
83
+
84
+ test('should handle empty signals object', () => {
85
+ const result = resolve({})
86
+
87
+ expect(result.ok).toBe(true)
88
+ if (result.ok) {
89
+ expect(result.values).toEqual({})
90
+ }
91
+ })
92
+
93
+ test('should handle complex nested object signals', () => {
94
+ const user = state({ name: 'Alice', age: 25 })
95
+ const settings = state({ theme: 'dark', lang: 'en' })
96
+
97
+ const result = resolve({ user, settings })
98
+
99
+ expect(result.ok).toBe(true)
100
+ if (result.ok) {
101
+ expect(result.values.user.name).toBe('Alice')
102
+ expect(result.values.user.age).toBe(25)
103
+ expect(result.values.settings.theme).toBe('dark')
104
+ expect(result.values.settings.lang).toBe('en')
105
+ }
106
+ })
107
+
108
+ test('should handle async computed signals that resolve', async () => {
109
+ const wait = (ms: number) =>
110
+ new Promise(resolve => setTimeout(resolve, ms))
111
+
112
+ const asyncSignal = computed(async () => {
113
+ await wait(10)
114
+ return 'async result'
115
+ })
116
+
117
+ // Initially should be pending
118
+ let result = resolve({ asyncSignal })
119
+ expect(result.ok).toBe(false)
120
+ expect(result.pending).toBe(true)
121
+
122
+ // Wait for resolution
123
+ await wait(20)
124
+
125
+ result = resolve({ asyncSignal })
126
+ expect(result.ok).toBe(true)
127
+ if (result.ok) {
128
+ expect(result.values.asyncSignal).toBe('async result')
129
+ }
130
+ })
131
+
132
+ test('should handle async computed signals that error', async () => {
133
+ const wait = (ms: number) =>
134
+ new Promise(resolve => setTimeout(resolve, ms))
135
+
136
+ const asyncError = computed(async () => {
137
+ await wait(10)
138
+ throw new Error('Async error')
139
+ })
140
+
141
+ // Initially should be pending
142
+ let result = resolve({ asyncError })
143
+ expect(result.ok).toBe(false)
144
+ expect(result.pending).toBe(true)
145
+
146
+ // Wait for error
147
+ await wait(20)
148
+
149
+ result = resolve({ asyncError })
150
+ expect(result.ok).toBe(false)
151
+ expect(result.errors).toBeDefined()
152
+ if (result.errors) {
153
+ expect(result.errors[0].message).toBe('Async error')
154
+ }
155
+ })
156
+ })