@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
632 lines (521 loc) • 15.3 kB
text/typescript
import { describe, expect, test } from 'bun:test'
import {
batch,
type CollectionChanges,
createCollection,
createEffect,
createList,
createScope,
createState,
createStore,
isCollection,
isList,
} from '../index.ts'
/* === Utility Functions === */
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/* === Tests === */
describe('Collection', () => {
describe('createCollection', () => {
test('should create a collection with initial values', () => {
const col = createCollection(() => () => {}, { value: [1, 2, 3] })
expect(col.get()).toEqual([1, 2, 3])
expect(col.length).toBe(3)
expect(isCollection(col)).toBe(true)
})
test('should create an empty collection', () => {
const col = createCollection<number>(() => () => {})
expect(col.get()).toEqual([])
expect(col.length).toBe(0)
})
test('should have Symbol.toStringTag of "Collection"', () => {
const col = createCollection(() => () => {}, { value: [1] })
expect(col[Symbol.toStringTag]).toBe('Collection')
})
test('should have Symbol.isConcatSpreadable set to true', () => {
const col = createCollection(() => () => {}, { value: [1] })
expect(col[Symbol.isConcatSpreadable]).toBe(true)
})
test('should support at(), byKey(), keyAt(), indexOfKey()', () => {
const col = createCollection(() => () => {}, {
value: [
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' },
],
keyConfig: item => item.id,
})
expect(col.keyAt(0)).toBe('a')
expect(col.keyAt(1)).toBe('b')
expect(col.indexOfKey('b')).toBe(1)
// biome-ignore lint/style/noNonNullAssertion: test
expect(col.byKey('a')!.get()).toEqual({ id: 'a', name: 'Alice' })
// biome-ignore lint/style/noNonNullAssertion: test
expect(col.at(1)!.get()).toEqual({ id: 'b', name: 'Bob' })
})
test('should support iteration', () => {
const col = createCollection(() => () => {}, {
value: [10, 20, 30],
})
const values = []
for (const signal of col) values.push(signal.get())
expect(values).toEqual([10, 20, 30])
})
test('should support custom key config with string prefix', () => {
const col = createCollection(() => () => {}, {
value: [10, 20],
keyConfig: 'item-',
})
expect(col.keyAt(0)).toBe('item-0')
expect(col.keyAt(1)).toBe('item-1')
// biome-ignore lint/style/noNonNullAssertion: test
expect(col.byKey('item-0')!.get()).toBe(10)
})
test('should support custom createItem factory', () => {
let guardCalled = false
const col = createCollection(() => () => {}, {
value: [5, 10],
createItem: value =>
createState(value, {
guard: (v): v is number => {
guardCalled = true
return typeof v === 'number'
},
}),
})
expect(col.get()).toEqual([5, 10])
expect(guardCalled).toBe(true)
})
})
describe('isCollection', () => {
test('should identify collection signals', () => {
const col = createCollection(() => () => {}, { value: [1] })
expect(isCollection(col)).toBe(true)
})
test('should return false for non-collection values', () => {
expect(isCollection(42)).toBe(false)
expect(isCollection(null)).toBe(false)
expect(isCollection({})).toBe(false)
expect(
isList(createCollection(() => () => {}, { value: [1] })),
).toBe(false)
})
})
describe('Watched Lifecycle', () => {
test('should call start callback on first effect access', () => {
let started = false
let cleaned = false
const col = createCollection(
() => {
started = true
return () => {
cleaned = true
}
},
{ value: [1] },
)
expect(started).toBe(false)
const dispose = createScope(() => {
createEffect(() => {
void col.length
})
})
expect(started).toBe(true)
expect(cleaned).toBe(false)
dispose()
expect(cleaned).toBe(true)
})
test('should activate via keys() access in effect', () => {
let started = false
const col = createCollection(
() => {
started = true
return () => {}
},
{ value: [1] },
)
expect(started).toBe(false)
const dispose = createScope(() => {
createEffect(() => {
void Array.from(col.keys())
})
})
expect(started).toBe(true)
dispose()
})
})
describe('applyChanges', () => {
test('should add items', () => {
let apply:
| ((changes: CollectionChanges<number>) => void)
| undefined
const col = createCollection<number>(applyChanges => {
apply = applyChanges
return () => {}
})
const values: number[][] = []
const dispose = createScope(() => {
createEffect(() => {
values.push(col.get())
})
})
expect(values).toEqual([[]])
// biome-ignore lint/style/noNonNullAssertion: test
apply!({ add: [1, 2] })
expect(values.length).toBe(2)
expect(values[1]).toEqual([1, 2])
expect(col.length).toBe(2)
dispose()
})
test('should change item values', () => {
let apply:
| ((
changes: CollectionChanges<{ id: string; val: number }>,
) => void)
| undefined
const col = createCollection(
applyChanges => {
apply = applyChanges
return () => {}
},
{
value: [{ id: 'x', val: 1 }],
keyConfig: item => item.id,
},
)
const values: { id: string; val: number }[][] = []
const dispose = createScope(() => {
createEffect(() => {
values.push(col.get())
})
})
expect(values[0]).toEqual([{ id: 'x', val: 1 }])
// biome-ignore lint/style/noNonNullAssertion: test
apply!({ change: [{ id: 'x', val: 42 }] })
expect(values.length).toBe(2)
expect(values[1]).toEqual([{ id: 'x', val: 42 }])
dispose()
})
test('should remove items', () => {
let apply:
| ((
changes: CollectionChanges<{ id: string; v: number }>,
) => void)
| undefined
const col = createCollection(
applyChanges => {
apply = applyChanges
return () => {}
},
{
value: [
{ id: 'a', v: 1 },
{ id: 'b', v: 2 },
{ id: 'c', v: 3 },
],
keyConfig: item => item.id,
},
)
const values: { id: string; v: number }[][] = []
const dispose = createScope(() => {
createEffect(() => {
values.push(col.get())
})
})
expect(values[0]).toEqual([
{ id: 'a', v: 1 },
{ id: 'b', v: 2 },
{ id: 'c', v: 3 },
])
// biome-ignore lint/style/noNonNullAssertion: test
apply!({ remove: [{ id: 'b', v: 2 }] })
expect(values.length).toBe(2)
expect(values[1]).toEqual([
{ id: 'a', v: 1 },
{ id: 'c', v: 3 },
])
expect(col.length).toBe(2)
dispose()
})
test('should handle mixed add/change/remove', () => {
let apply:
| ((
changes: CollectionChanges<{ id: string; v: number }>,
) => void)
| undefined
const col = createCollection(
applyChanges => {
apply = applyChanges
return () => {}
},
{
value: [
{ id: 'a', v: 1 },
{ id: 'b', v: 2 },
],
keyConfig: item => item.id,
},
)
const values: { id: string; v: number }[][] = []
const dispose = createScope(() => {
createEffect(() => {
values.push(col.get())
})
})
// biome-ignore lint/style/noNonNullAssertion: test
apply!({
add: [{ id: 'c', v: 3 }],
change: [{ id: 'a', v: 10 }],
remove: [{ id: 'b', v: 2 }],
})
expect(values.length).toBe(2)
expect(values[1]).toEqual([
{ id: 'a', v: 10 },
{ id: 'c', v: 3 },
])
dispose()
})
test('should skip when no changes provided', () => {
let apply:
| ((changes: CollectionChanges<number>) => void)
| undefined
const col = createCollection(
applyChanges => {
apply = applyChanges
return () => {}
},
{ value: [1] },
)
let callCount = 0
const dispose = createScope(() => {
createEffect(() => {
void col.get()
callCount++
})
})
expect(callCount).toBe(1)
// biome-ignore lint/style/noNonNullAssertion: test
apply!({})
expect(callCount).toBe(1)
dispose()
})
test('should trigger effects on structural changes', () => {
let apply:
| ((changes: CollectionChanges<string>) => void)
| undefined
const col = createCollection<string>(applyChanges => {
apply = applyChanges
return () => {}
})
let effectCount = 0
const dispose = createScope(() => {
createEffect(() => {
void col.length
effectCount++
})
})
expect(effectCount).toBe(1)
// biome-ignore lint/style/noNonNullAssertion: test
apply!({ add: ['hello'] })
expect(effectCount).toBe(2)
expect(col.length).toBe(1)
dispose()
})
test('should batch multiple calls', () => {
let apply:
| ((changes: CollectionChanges<number>) => void)
| undefined
const col = createCollection<number>(applyChanges => {
apply = applyChanges
return () => {}
})
let effectCount = 0
const dispose = createScope(() => {
createEffect(() => {
void col.get()
effectCount++
})
})
expect(effectCount).toBe(1)
batch(() => {
// biome-ignore lint/style/noNonNullAssertion: test
apply!({ add: [1] })
// biome-ignore lint/style/noNonNullAssertion: test
apply!({ add: [2] })
})
expect(effectCount).toBe(2)
expect(col.get()).toEqual([1, 2])
dispose()
})
})
describe('deriveCollection', () => {
test('should transform list values with sync callback', () => {
const numbers = createList([1, 2, 3])
const doubled = numbers.deriveCollection((v: number) => v * 2)
expect(doubled.get()).toEqual([2, 4, 6])
expect(doubled.length).toBe(3)
})
test('should transform values with async callback', async () => {
const numbers = createList([1, 2, 3])
const doubled = numbers.deriveCollection(
async (v: number, abort: AbortSignal) => {
await wait(10)
if (abort.aborted) throw new Error('Aborted')
return v * 2
},
)
// Trigger computation
for (let i = 0; i < doubled.length; i++) {
try {
doubled.at(i)?.get()
} catch {
// UnsetSignalValueError before resolution
}
}
await wait(50)
expect(doubled.get()).toEqual([2, 4, 6])
})
test('should handle empty source list', () => {
const empty = createList<number>([])
const doubled = empty.deriveCollection((v: number) => v * 2)
expect(doubled.get()).toEqual([])
expect(doubled.length).toBe(0)
})
test('should return Signal at index', () => {
const list = createList([1, 2, 3])
const doubled = list.deriveCollection((v: number) => v * 2)
expect(doubled.at(0)?.get()).toBe(2)
expect(doubled.at(1)?.get()).toBe(4)
expect(doubled.at(2)?.get()).toBe(6)
expect(doubled.at(5)).toBeUndefined()
})
test('should return Signal by source key', () => {
const list = createList([10, 20])
const doubled = list.deriveCollection((v: number) => v * 2)
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
const key0 = list.keyAt(0)!
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
const key1 = list.keyAt(1)!
expect(doubled.byKey(key0)?.get()).toBe(20)
expect(doubled.byKey(key1)?.get()).toBe(40)
})
test('should support keyAt, indexOfKey, and keys', () => {
const list = createList([10, 20, 30])
const col = list.deriveCollection((v: number) => v)
const key0 = col.keyAt(0)
expect(key0).toBeDefined()
expect(typeof key0).toBe('string')
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
expect(col.indexOfKey(key0!)).toBe(0)
expect([...col.keys()]).toHaveLength(3)
})
test('should support for...of via Symbol.iterator', () => {
const list = createList([1, 2, 3])
const doubled = list.deriveCollection((v: number) => v * 2)
const signals = [...doubled]
expect(signals).toHaveLength(3)
// biome-ignore lint/style/noNonNullAssertion: test
expect(signals[0]!.get()).toBe(2)
// biome-ignore lint/style/noNonNullAssertion: test
expect(signals[1]!.get()).toBe(4)
// biome-ignore lint/style/noNonNullAssertion: test
expect(signals[2]!.get()).toBe(6)
})
test('should react to source additions', () => {
const list = createList([1, 2])
const doubled = list.deriveCollection((v: number) => v * 2)
let result: number[] = []
let effectCount = 0
createEffect(() => {
result = doubled.get()
effectCount++
})
expect(result).toEqual([2, 4])
expect(effectCount).toBe(1)
list.add(3)
expect(result).toEqual([2, 4, 6])
expect(effectCount).toBe(2)
})
test('should react to source removals', () => {
const list = createList([1, 2, 3])
const doubled = list.deriveCollection((v: number) => v * 2)
expect(doubled.get()).toEqual([2, 4, 6])
list.remove(1)
expect(doubled.get()).toEqual([2, 6])
expect(doubled.length).toBe(2)
})
test('should react to item mutations', () => {
const list = createList([1, 2])
const doubled = list.deriveCollection((v: number) => v * 2)
let result: number[] = []
createEffect(() => {
result = doubled.get()
})
expect(result).toEqual([2, 4])
list.at(0)?.set(5)
expect(result).toEqual([10, 4])
})
test('async collection should react to changes', async () => {
const list = createList([1, 2])
const doubled = list.deriveCollection(
async (v: number, abort: AbortSignal) => {
await wait(5)
if (abort.aborted) throw new Error('Aborted')
return v * 2
},
)
const values: number[][] = []
createEffect(() => {
values.push([...doubled.get()])
})
await wait(20)
expect(values[values.length - 1]).toEqual([2, 4])
list.add(3)
await wait(20)
expect(values[values.length - 1]).toEqual([2, 4, 6])
})
test('should chain from collection', () => {
const list = createList([1, 2, 3])
const doubled = list.deriveCollection((v: number) => v * 2)
const quadrupled = doubled.deriveCollection((v: number) => v * 2)
expect(quadrupled.get()).toEqual([4, 8, 12])
list.add(4)
expect(quadrupled.get()).toEqual([4, 8, 12, 16])
})
test('should chain from createCollection source', () => {
const col = createCollection(() => () => {}, { value: [1, 2, 3] })
const doubled = col.deriveCollection((v: number) => v * 2)
expect(doubled.get()).toEqual([2, 4, 6])
expect(isCollection(doubled)).toBe(true)
})
test('should propagate errors from per-item memos', () => {
const list = createList([1, 2, 3])
const mapped = list.deriveCollection((v: number) => {
if (v === 2) throw new Error('bad item')
return v * 2
})
expect(() => mapped.get()).toThrow('bad item')
})
})
})
test('Type Inference for custom createItem', () => {
// This test primarily checks compilation types but also runtime presence
type TodoItem = { id: string; text: string; done: boolean }
const col = createCollection(() => () => {}, {
keyConfig: 'todo',
createItem: createStore<TodoItem>,
})
const byKey = col.byKey('todo0')
// Runtime check
expect(byKey).toBeUndefined()
// Type check
type Expect<T extends true> = T
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
? true
: false
type _Test = Expect<
Equal<
typeof byKey,
ReturnType<typeof createStore<TodoItem>> | undefined
>
>
})