@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,451 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type Computed,
4
+ computed,
5
+ isComputed,
6
+ isState,
7
+ isStore,
8
+ type Signal,
9
+ type State,
10
+ type Store,
11
+ state,
12
+ store,
13
+ toMutableSignal,
14
+ toSignal,
15
+ type UnknownRecord,
16
+ } from '..'
17
+
18
+ /* === Tests === */
19
+
20
+ describe('toSignal', () => {
21
+ describe('type inference and runtime behavior', () => {
22
+ test('converts array to Store<Record<string, T>>', () => {
23
+ const arr = [
24
+ { id: 1, name: 'Alice' },
25
+ { id: 2, name: 'Bob' },
26
+ ]
27
+ const result = toSignal(arr)
28
+
29
+ // Runtime behavior
30
+ expect(isStore(result)).toBe(true)
31
+ expect(result['0'].get()).toEqual({ id: 1, name: 'Alice' })
32
+ expect(result['1'].get()).toEqual({ id: 2, name: 'Bob' })
33
+
34
+ // Type inference test - now correctly returns Store<Record<string, {id: number, name: string}>>
35
+ const typedResult: Store<
36
+ Record<string, { id: number; name: string }>
37
+ > = result
38
+ expect(typedResult).toBeDefined()
39
+ })
40
+
41
+ test('converts empty array to Store<Record<string, never>>', () => {
42
+ const arr: never[] = []
43
+ const result = toSignal(arr)
44
+
45
+ // Runtime behavior
46
+ expect(isStore(result)).toBe(true)
47
+ expect(Object.keys(result).length).toBe(0)
48
+ })
49
+
50
+ test('converts record to Store<T>', () => {
51
+ const record = { name: 'Alice', age: 30 }
52
+ const result = toSignal(record)
53
+
54
+ // Runtime behavior
55
+ expect(isStore(result)).toBe(true)
56
+ expect(result.name.get()).toBe('Alice')
57
+ expect(result.age.get()).toBe(30)
58
+
59
+ // Type inference test - should be Store<{name: string, age: number}>
60
+ const typedResult: Store<{ name: string; age: number }> = result
61
+ expect(typedResult).toBeDefined()
62
+ })
63
+
64
+ test('passes through existing Store unchanged', () => {
65
+ const originalStore = store({ count: 5 })
66
+ const result = toSignal(originalStore)
67
+
68
+ // Runtime behavior
69
+ expect(result).toBe(originalStore) // Should be the same instance
70
+ expect(isStore(result)).toBe(true)
71
+ expect(result.count.get()).toBe(5)
72
+
73
+ // Type inference test - should remain Store<{count: number}>
74
+ const typedResult: Store<{ count: number }> = result
75
+ expect(typedResult).toBeDefined()
76
+ })
77
+
78
+ test('passes through existing State unchanged', () => {
79
+ const originalState = state(42)
80
+ const result = toSignal(originalState)
81
+
82
+ // Runtime behavior
83
+ expect(result).toBe(originalState) // Should be the same instance
84
+ expect(isState(result)).toBe(true)
85
+ expect(result.get()).toBe(42)
86
+
87
+ // Type inference test - should remain State<number>
88
+ const typedResult: State<number> = result
89
+ expect(typedResult).toBeDefined()
90
+ })
91
+
92
+ test('passes through existing Computed unchanged', () => {
93
+ const originalComputed = computed(() => 'hello world')
94
+ const result = toSignal(originalComputed)
95
+
96
+ // Runtime behavior
97
+ expect(result).toBe(originalComputed) // Should be the same instance
98
+ expect(isComputed(result)).toBe(true)
99
+ expect(result.get()).toBe('hello world')
100
+
101
+ // Type inference test - should remain Computed<string>
102
+ const typedResult: Computed<string> = result
103
+ expect(typedResult).toBeDefined()
104
+ })
105
+
106
+ test('converts function to Computed<T>', () => {
107
+ const fn = () => Math.random()
108
+ const result = toSignal(fn)
109
+
110
+ // Runtime behavior - functions are correctly converted to Computed
111
+ expect(isComputed(result)).toBe(true)
112
+ expect(typeof result.get()).toBe('number')
113
+
114
+ // Type inference test - should be Computed<number>
115
+ const typedResult: Computed<number> = result
116
+ expect(typedResult).toBeDefined()
117
+ })
118
+
119
+ test('converts primitive to State<T>', () => {
120
+ const num = 42
121
+ const result = toSignal(num)
122
+
123
+ // Runtime behavior - primitives are correctly converted to State
124
+ expect(isState(result)).toBe(true)
125
+ expect(result.get()).toBe(42)
126
+
127
+ // Type inference test - should be State<number>
128
+ const typedResult: State<number> = result
129
+ expect(typedResult).toBeDefined()
130
+ })
131
+
132
+ test('converts object to State<T>', () => {
133
+ const obj = new Date('2024-01-01')
134
+ const result = toSignal(obj)
135
+
136
+ // Runtime behavior - objects are correctly converted to State
137
+ expect(isState(result)).toBe(true)
138
+ expect(result.get()).toBe(obj)
139
+
140
+ // Type inference test - should be State<Date>
141
+ const typedResult: State<Date> = result
142
+ expect(typedResult).toBeDefined()
143
+ })
144
+ })
145
+
146
+ describe('edge cases', () => {
147
+ test('handles nested arrays', () => {
148
+ const nestedArr = [
149
+ [1, 2],
150
+ [3, 4],
151
+ ]
152
+ const result = toSignal(nestedArr)
153
+
154
+ expect(isStore(result)).toBe(true)
155
+ // With the fixed behavior, nested arrays should be recovered as arrays
156
+ const firstElement = result[0].get()
157
+ const secondElement = result[1].get()
158
+
159
+ // The expected behavior - nested arrays are recovered as arrays
160
+ expect(firstElement).toEqual([1, 2])
161
+ expect(secondElement).toEqual([3, 4])
162
+ })
163
+
164
+ test('handles arrays with mixed types', () => {
165
+ const mixedArr = [1, 'hello', { key: 'value' }]
166
+ const result = toSignal(mixedArr)
167
+
168
+ expect(isStore(result)).toBe(true)
169
+ expect(result['0'].get()).toBe(1)
170
+ expect(result['1'].get()).toBe('hello')
171
+ expect(result['2'].get()).toEqual({ key: 'value' })
172
+ })
173
+
174
+ test('handles sparse arrays', () => {
175
+ const sparseArr = new Array(3)
176
+ sparseArr[1] = 'middle'
177
+ const result = toSignal(sparseArr)
178
+
179
+ expect(isStore(result)).toBe(true)
180
+ expect('0' in result).toBe(false)
181
+ expect(result['1'].get()).toBe('middle')
182
+ expect('2' in result).toBe(false)
183
+ })
184
+ })
185
+ })
186
+
187
+ describe('toMutableSignal', () => {
188
+ describe('type inference and runtime behavior', () => {
189
+ test('converts array to Store<Record<string, T>>', () => {
190
+ const arr = [
191
+ { id: 1, name: 'Alice' },
192
+ { id: 2, name: 'Bob' },
193
+ ]
194
+ const result = toMutableSignal(arr)
195
+
196
+ // Runtime behavior
197
+ expect(isStore(result)).toBe(true)
198
+ expect(result['0'].get()).toEqual({ id: 1, name: 'Alice' })
199
+ expect(result['1'].get()).toEqual({ id: 2, name: 'Bob' })
200
+
201
+ // Type inference test - now correctly returns Store<Record<string, {id: number, name: string}>>
202
+ const typedResult: Store<
203
+ Record<string, { id: number; name: string }>
204
+ > = result
205
+ expect(typedResult).toBeDefined()
206
+ })
207
+
208
+ test('converts record to Store<T>', () => {
209
+ const record = { name: 'Alice', age: 30 }
210
+ const result = toMutableSignal(record)
211
+
212
+ // Runtime behavior
213
+ expect(isStore(result)).toBe(true)
214
+ expect(result.name.get()).toBe('Alice')
215
+ expect(result.age.get()).toBe(30)
216
+
217
+ // Type inference test - should be Store<{name: string, age: number}>
218
+ const typedResult: Store<{ name: string; age: number }> = result
219
+ expect(typedResult).toBeDefined()
220
+ })
221
+
222
+ test('passes through existing Store unchanged', () => {
223
+ const originalStore = store({ count: 5 })
224
+ const result = toMutableSignal(originalStore)
225
+
226
+ // Runtime behavior
227
+ expect(result).toBe(originalStore) // Should be the same instance
228
+ expect(isStore(result)).toBe(true)
229
+ expect(result.count.get()).toBe(5)
230
+ })
231
+
232
+ test('passes through existing State unchanged', () => {
233
+ const originalState = state(42)
234
+ const result = toMutableSignal(originalState)
235
+
236
+ // Runtime behavior
237
+ expect(result).toBe(originalState) // Should be the same instance
238
+ expect(isState(result)).toBe(true)
239
+ expect(result.get()).toBe(42)
240
+
241
+ // Type inference test - should be State<number>
242
+ const typedResult: State<number> = result
243
+ expect(typedResult).toBeDefined()
244
+ })
245
+
246
+ test('converts primitive to State<T>', () => {
247
+ const num = 42
248
+ const result = toMutableSignal(num)
249
+
250
+ // Runtime behavior - primitives are correctly converted to State
251
+ expect(isState(result)).toBe(true)
252
+ expect(result.get()).toBe(42)
253
+ })
254
+
255
+ test('converts object to State<T> (not Store)', () => {
256
+ const obj = new Date('2024-01-01')
257
+ const result = toMutableSignal(obj)
258
+
259
+ // Runtime behavior - objects are correctly converted to State
260
+ expect(isState(result)).toBe(true)
261
+ expect(result.get()).toBe(obj)
262
+
263
+ // Type inference test - should be State<Date>
264
+ const typedResult: State<Date> = result
265
+ expect(typedResult).toBeDefined()
266
+ })
267
+ })
268
+
269
+ describe('differences from toSignal', () => {
270
+ test('does not accept functions (only mutable signals)', () => {
271
+ // toMutableSignal should not have a function overload
272
+ // This test documents the expected behavior difference
273
+ const fn = () => 'test'
274
+ const result = toMutableSignal(fn)
275
+
276
+ // Should treat function as a regular value and create State
277
+ expect(isState(result)).toBe(true)
278
+ expect(result.get()).toBe(fn)
279
+ })
280
+
281
+ test('does not accept Computed signals', () => {
282
+ // toMutableSignal should not accept Computed signals
283
+ const comp = computed(() => 'computed value')
284
+ const result = toMutableSignal(comp)
285
+
286
+ // Should treat Computed as a regular object and create State
287
+ expect(isState(result)).toBe(true)
288
+ expect(result.get()).toBe(comp)
289
+ })
290
+ })
291
+ })
292
+
293
+ describe('Signal compatibility', () => {
294
+ test('all results implement Signal<T> interface', () => {
295
+ const arraySignal = toSignal([1, 2, 3])
296
+ const recordSignal = toSignal({ a: 1, b: 2 })
297
+ const primitiveSignal = toSignal(42)
298
+ const functionSignal = toSignal(() => 'hello')
299
+ const stateSignal = toSignal(state(true))
300
+
301
+ // All should have get() method
302
+ expect(typeof arraySignal.get).toBe('function')
303
+ expect(typeof recordSignal.get).toBe('function')
304
+ expect(typeof primitiveSignal.get).toBe('function')
305
+ expect(typeof functionSignal.get).toBe('function')
306
+ expect(typeof stateSignal.get).toBe('function')
307
+
308
+ // All should be assignable to Signal<T>
309
+ const signals: Signal<unknown & {}>[] = [
310
+ arraySignal,
311
+ recordSignal,
312
+ primitiveSignal,
313
+ functionSignal,
314
+ stateSignal,
315
+ ]
316
+ expect(signals.length).toBe(5)
317
+ })
318
+ })
319
+
320
+ describe('Type precision tests', () => {
321
+ test('array type should infer element type correctly', () => {
322
+ // Test that arrays infer the correct element type
323
+ const stringArray = ['a', 'b', 'c']
324
+ const stringArraySignal = toSignal(stringArray)
325
+
326
+ // Should be Store<Record<string, string>>
327
+ expect(stringArraySignal['0'].get()).toBe('a')
328
+
329
+ const numberArray = [1, 2, 3]
330
+ const numberArraySignal = toSignal(numberArray)
331
+
332
+ // Should be Store<Record<string, number>>
333
+ expect(typeof numberArraySignal['0'].get()).toBe('number')
334
+ })
335
+
336
+ test('complex object arrays maintain precise typing', () => {
337
+ interface User {
338
+ id: number
339
+ name: string
340
+ email: string
341
+ }
342
+
343
+ const users: User[] = [
344
+ { id: 1, name: 'Alice', email: 'alice@example.com' },
345
+ { id: 2, name: 'Bob', email: 'bob@example.com' },
346
+ ]
347
+
348
+ const usersSignal = toSignal(users)
349
+
350
+ // Should maintain User type for each element
351
+ const firstUser = usersSignal['0'].get()
352
+ expect(firstUser.id).toBe(1)
353
+ expect(firstUser.name).toBe('Alice')
354
+ expect(firstUser.email).toBe('alice@example.com')
355
+ })
356
+
357
+ describe('Type inference issues', () => {
358
+ test('demonstrates current type inference problem', () => {
359
+ // Current issue: when passing an array, T is inferred as the array type
360
+ // instead of the element type, causing type compatibility problems
361
+ const items = [{ id: 1 }, { id: 2 }]
362
+ const result = toSignal(items)
363
+
364
+ // This should work but may have type issues in external libraries
365
+ // The return type should be Store<Record<string, {id: number}>>
366
+ // But currently it might be inferred as Store<Record<string, {id: number}[]>>
367
+
368
+ // Let's verify the actual behavior
369
+ expect(isStore(result)).toBe(true)
370
+ expect(result['0'].get()).toEqual({ id: 1 })
371
+ expect(result['1'].get()).toEqual({ id: 2 })
372
+
373
+ // Type assertion test - this should now work with correct typing
374
+ const typedResult: Store<Record<string, { id: number }>> = result
375
+ expect(typedResult).toBeDefined()
376
+
377
+ // Simulate external library usage where P[K] represents element type
378
+ interface ExternalLibraryConstraint<P extends UnknownRecord> {
379
+ process<K extends keyof P>(signal: Signal<P[K]>): void
380
+ }
381
+
382
+ // This should work if types are correct
383
+ const processor: ExternalLibraryConstraint<
384
+ Record<string, { id: number }>
385
+ > = {
386
+ process: <K extends keyof Record<string, { id: number }>>(
387
+ signal: Signal<Record<string, { id: number }>[K]>,
388
+ ) => {
389
+ // Process the signal
390
+ const value = signal.get()
391
+ expect(value).toHaveProperty('id')
392
+ },
393
+ }
394
+
395
+ // This call should work without type errors
396
+ processor.process(result['0'])
397
+ })
398
+
399
+ test('verifies fixed type inference for external library compatibility', () => {
400
+ // This test ensures the fix for the type inference issue works
401
+ // Fixed: toSignal<T extends unknown & {}>(value: T[]): Store<Record<string, T>>
402
+ // Now T = {id: number} (element type), T[] = {id: number}[] (array of elements)
403
+ // Return type: Store<Record<string, {id: number}>> (correct)
404
+
405
+ const items = [
406
+ { id: 1, name: 'Alice' },
407
+ { id: 2, name: 'Bob' },
408
+ ]
409
+ const signal = toSignal(items)
410
+
411
+ // Type should be Store<Record<string, {id: number, name: string}>>
412
+ // Each property signal should be Signal<{id: number, name: string}>
413
+ const firstItemSignal = signal['0']
414
+ const secondItemSignal = signal['1']
415
+
416
+ // Runtime behavior works correctly
417
+ expect(isStore(signal)).toBe(true)
418
+ expect(firstItemSignal.get()).toEqual({ id: 1, name: 'Alice' })
419
+ expect(secondItemSignal.get()).toEqual({ id: 2, name: 'Bob' })
420
+
421
+ // Type inference should now work correctly:
422
+ const properlyTyped: Store<
423
+ Record<string, { id: number; name: string }>
424
+ > = signal
425
+ expect(properlyTyped).toBeDefined()
426
+
427
+ // These should work without type errors in external libraries
428
+ // that expect Signal<P[K]> where P[K] is the individual element type
429
+ interface ExternalAPI<P extends UnknownRecord> {
430
+ process<K extends keyof P>(key: K, signal: Signal<P[K]>): P[K]
431
+ }
432
+
433
+ const api: ExternalAPI<
434
+ Record<string, { id: number; name: string }>
435
+ > = {
436
+ process: (_key, signal) => signal.get(),
437
+ }
438
+
439
+ // These calls should work with proper typing now
440
+ const result1 = api.process('0', firstItemSignal)
441
+ const result2 = api.process('1', secondItemSignal)
442
+
443
+ expect(result1).toEqual({ id: 1, name: 'Alice' })
444
+ expect(result2).toEqual({ id: 2, name: 'Bob' })
445
+
446
+ // Verify the types are precise
447
+ expect(typeof result1.id).toBe('number')
448
+ expect(typeof result1.name).toBe('string')
449
+ })
450
+ })
451
+ })