UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management primitives library for TypeScript.

632 lines (521 loc) 15.3 kB
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 > > })