UNPKG

@zeix/cause-effect

Version:

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

873 lines (741 loc) 22.7 kB
import { describe, expect, test } from 'bun:test' import { batch, createEffect, createList, createMemo, createScope, createState, createStore, createTask, isList, isMemo, match, } from '../index.ts' /* === Utility Functions === */ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) describe('List', () => { describe('createList', () => { test('should return initial values from get()', () => { const list = createList([1, 2, 3]) expect(list.get()).toEqual([1, 2, 3]) }) test('should work with object items', () => { const list = createList([ { name: 'Alice', tags: ['admin'] }, { name: 'Bob', tags: ['user'] }, ]) expect(list.get()).toEqual([ { name: 'Alice', tags: ['admin'] }, { name: 'Bob', tags: ['user'] }, ]) }) test('should handle empty initial array', () => { const list = createList<number>([]) expect(list.get()).toEqual([]) expect(list.length).toBe(0) }) test('should have Symbol.toStringTag of "List"', () => { const list = createList([1]) expect(list[Symbol.toStringTag]).toBe('List') }) test('should have Symbol.isConcatSpreadable set to true', () => { const list = createList([1]) expect(list[Symbol.isConcatSpreadable]).toBe(true) }) }) describe('isList', () => { test('should identify list signals', () => { expect(isList(createList([1]))).toBe(true) }) test('should return false for non-list values', () => { expect(isList(42)).toBe(false) expect(isList(null)).toBe(false) expect(isList({})).toBe(false) expect(isMemo(createList([1]))).toBe(false) }) }) describe('at', () => { test('should return State signal at index', () => { const list = createList(['a', 'b', 'c']) expect(list.at(0)?.get()).toBe('a') expect(list.at(1)?.get()).toBe('b') expect(list.at(2)?.get()).toBe('c') }) test('should return undefined for out-of-bounds index', () => { const list = createList(['a']) expect(list.at(5)).toBeUndefined() }) test('should allow mutation via returned State signal', () => { const list = createList(['a', 'b']) list.at(0)?.set('alpha') expect(list.at(0)?.get()).toBe('alpha') }) }) describe('set', () => { test('should replace entire array', () => { const list = createList([1, 2, 3]) list.set([4, 5]) expect(list.get()).toEqual([4, 5]) expect(list.length).toBe(2) }) test('should diff and update changed items', () => { const list = createList([1, 2, 3]) const signal0 = list.at(0) list.set([10, 2, 3]) // Same signal reference, updated value expect(signal0?.get()).toBe(10) }) test('should keep stable keys when reordering with content-based keyConfig', () => { type Item = { id: string; val: number } const list = createList<Item>( [ { id: 'a', val: 1 }, { id: 'b', val: 2 }, { id: 'c', val: 3 }, ], { keyConfig: item => item.id }, ) // Grab signal references by key before reorder const signalA = list.byKey('a') const signalB = list.byKey('b') const signalC = list.byKey('c') // Reverse order list.set([ { id: 'c', val: 3 }, { id: 'b', val: 2 }, { id: 'a', val: 1 }, ]) // Keys should follow items, not positions expect(list.byKey('a')?.get()).toEqual({ id: 'a', val: 1 }) expect(list.byKey('b')?.get()).toEqual({ id: 'b', val: 2 }) expect(list.byKey('c')?.get()).toEqual({ id: 'c', val: 3 }) // Signal references should be preserved (same State objects) expect(list.byKey('a')).toBe(signalA) expect(list.byKey('b')).toBe(signalB) expect(list.byKey('c')).toBe(signalC) // Key order should match new array order expect([...list.keys()]).toEqual(['c', 'b', 'a']) }) test('should detect duplicates in set() with content-based keyConfig', () => { const list = createList([{ id: 'a', val: 1 }], { keyConfig: item => item.id, }) expect(() => list.set([ { id: 'a', val: 1 }, { id: 'a', val: 2 }, ]), ).toThrow('already exists') }) }) describe('update', () => { test('should update via callback', () => { const list = createList([1, 2]) list.update(arr => [...arr, 3]) expect(list.get()).toEqual([1, 2, 3]) }) }) describe('add', () => { test('should append item and return key', () => { const list = createList(['apple', 'banana']) const key = list.add('cherry') expect(typeof key).toBe('string') expect(list.at(2)?.get()).toBe('cherry') expect(list.byKey(key)?.get()).toBe('cherry') }) test('should throw for null value', () => { const list = createList([1]) // @ts-expect-error - Testing invalid input expect(() => list.add(null)).toThrow() }) test('should throw DuplicateKeyError for duplicate keys', () => { const list = createList([{ id: 'a', val: 1 }], { keyConfig: item => item.id, }) expect(() => list.add({ id: 'a', val: 2 })).toThrow( 'already exists', ) }) }) describe('remove', () => { test('should remove by index', () => { const list = createList(['a', 'b', 'c']) list.remove(1) expect(list.get()).toEqual(['a', 'c']) expect(list.length).toBe(2) }) test('should remove by key', () => { const list = createList( [ { id: 'x', val: 1 }, { id: 'y', val: 2 }, ], { keyConfig: item => item.id }, ) list.remove('x') expect(list.get()).toEqual([{ id: 'y', val: 2 }]) }) test('should handle non-existent index gracefully', () => { const list = createList(['a']) expect(() => list.remove(5)).not.toThrow() expect(list.get()).toEqual(['a']) }) }) describe('replace', () => { test('should update the item signal value', () => { const list = createList(['a', 'b', 'c']) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(1)! list.replace(key, 'B') expect(list.byKey(key)?.get()).toBe('B') }) test('structural subscriber via keys() re-runs after replace(); byKey().set() does NOT', () => { const list = createList(['x', 'y']) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(0)! let effectCount = 0 // This effect subscribes structurally via keys() only — no list.get() call createEffect(() => { void [...list.keys()] effectCount++ }) expect(effectCount).toBe(1) // replace() propagates through node.sinks — structural subscriber re-runs list.replace(key, 'X') expect(effectCount).toBe(2) // byKey().set() does NOT propagate through node.sinks — structural subscriber does NOT re-run list.byKey(key)?.set('XX') expect(effectCount).toBe(2) }) test('direct subscriber via byKey().get() re-runs after replace()', () => { const list = createList(['a', 'b']) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(0)! let lastValue = '' let effectCount = 0 createEffect(() => { // biome-ignore lint/style/noNonNullAssertion: key is valid lastValue = list.byKey(key)!.get() effectCount++ }) expect(effectCount).toBe(1) expect(lastValue).toBe('a') list.replace(key, 'A') expect(effectCount).toBe(2) expect(lastValue).toBe('A') }) test('no-op on equal value (same reference)', () => { const obj = { id: 1 } const list = createList([obj]) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(0)! let effectCount = 0 createEffect(() => { list.get() effectCount++ }) expect(effectCount).toBe(1) list.replace(key, obj) expect(effectCount).toBe(1) }) test('no-op on missing key — does not throw and does not trigger effects', () => { const list = createList([1, 2, 3]) let effectCount = 0 createEffect(() => { list.get() effectCount++ }) expect(effectCount).toBe(1) expect(() => list.replace('nonexistent', 99)).not.toThrow() expect(effectCount).toBe(1) }) test('batch compatibility — effects run only once inside batch()', () => { const list = createList(['a', 'b', 'c']) // 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)! let effectCount = 0 createEffect(() => { list.get() effectCount++ }) expect(effectCount).toBe(1) batch(() => { list.replace(key0, 'A') list.replace(key1, 'B') }) expect(effectCount).toBe(2) expect(list.get()).toEqual(['A', 'B', 'c']) }) test('signal identity preserved — byKey() returns same signal before and after replace()', () => { const list = createList([10, 20]) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(0)! const signalBefore = list.byKey(key) list.replace(key, 99) const signalAfter = list.byKey(key) expect(signalBefore).toBe(signalAfter) }) test('replace() inside an effect does not cause the effect to re-run', () => { const list = createList(['a', 'b', 'c']) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(0)! let effectCount = 0 createEffect((): undefined => { effectCount++ list.replace(key, 'A') }) expect(effectCount).toBe(1) // ran once on creation expect(list.byKey(key)?.get()).toBe('A') // replace() must NOT have linked the item signal to this effect expect(effectCount).toBe(1) }) }) describe('sort', () => { test('should sort with default string comparison', () => { const list = createList([3, 1, 2]) list.sort() expect(list.get()).toEqual([1, 2, 3]) }) test('should sort with custom compare function', () => { const list = createList([3, 1, 2]) list.sort((a, b) => b - a) expect(list.get()).toEqual([3, 2, 1]) }) test('should trigger effects on sort', () => { const list = createList([3, 1, 2]) let effectCount = 0 let lastValue: number[] = [] createEffect(() => { lastValue = list.get() effectCount++ }) expect(effectCount).toBe(1) list.sort() expect(effectCount).toBe(2) expect(lastValue).toEqual([1, 2, 3]) }) }) describe('splice', () => { test('should remove elements', () => { const list = createList([1, 2, 3, 4]) const deleted = list.splice(1, 2) expect(deleted).toEqual([2, 3]) expect(list.get()).toEqual([1, 4]) }) test('should insert elements', () => { const list = createList([1, 3]) const deleted = list.splice(1, 0, 2) expect(deleted).toEqual([]) expect(list.get()).toEqual([1, 2, 3]) }) test('should replace elements', () => { const list = createList([1, 2, 3]) const deleted = list.splice(1, 1, 4, 5) expect(deleted).toEqual([2]) expect(list.get()).toEqual([1, 4, 5, 3]) }) test('should handle negative start index', () => { const list = createList([1, 2, 3]) const deleted = list.splice(-1, 1, 4) expect(deleted).toEqual([3]) expect(list.get()).toEqual([1, 2, 4]) }) test('should throw DuplicateKeyError for duplicate keys on insert', () => { const list = createList( [ { id: 'a', val: 1 }, { id: 'b', val: 2 }, ], { keyConfig: item => item.id }, ) expect(() => list.splice(1, 0, { id: 'a', val: 3 })).toThrow( 'already exists', ) }) test('splice replacing an item with the same key keeps byKey() defined', () => { const list = createList( [ { id: 'x', val: 1 }, { id: 'y', val: 2 }, ], { keyConfig: item => item.id }, ) // splice out { id: 'x', val: 1 } and insert a new { id: 'x', val: 99 } list.splice(0, 1, { id: 'x', val: 99 }) expect(list.byKey('x')).toBeDefined() expect(list.byKey('x')?.get()).toEqual({ id: 'x', val: 99 }) }) }) describe('length', () => { test('should return item count', () => { const list = createList([1, 2, 3]) expect(list.length).toBe(3) }) test('should update reactively with add and remove', () => { const list = createList([1, 2]) expect(list.length).toBe(2) list.add(3) expect(list.length).toBe(3) list.remove(0) expect(list.length).toBe(2) }) }) describe('Key-based Access', () => { test('keyAt should return key at index', () => { const list = createList([10, 20, 30]) const key0 = list.keyAt(0) expect(key0).toBeDefined() expect(typeof key0).toBe('string') }) test('indexOfKey should return index for key', () => { const list = createList([10, 20]) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(0)! expect(list.indexOfKey(key)).toBe(0) }) test('byKey should return State signal for key', () => { const list = createList([10, 20]) // biome-ignore lint/style/noNonNullAssertion: index is within bounds const key = list.keyAt(0)! expect(list.byKey(key)?.get()).toBe(10) }) test('byKey allows mutation of item', () => { const list = createList([{ id: 'w1', val: 1 }], { keyConfig: w => w.id, }) expect(list.byKey('w1')?.get()).toEqual({ id: 'w1', val: 1 }) list.byKey('w1')?.update(v => ({ ...v, val: 2 })) expect(list.byKey('w1')?.get()).toEqual({ id: 'w1', val: 2 }) }) test('keys should return iterator of all keys', () => { const list = createList([10, 20, 30]) const allKeys = [...list.keys()] expect(allKeys).toHaveLength(3) // biome-ignore lint/style/noNonNullAssertion: test expect(list.byKey(allKeys[0]!)?.get()).toBe(10) }) }) describe('options.keyConfig', () => { test('should use function to generate keys', () => { const list = createList( [ { id: 'a', value: 1 }, { id: 'b', value: 2 }, ], { keyConfig: item => item.id }, ) expect(list.byKey('a')?.get()).toEqual({ id: 'a', value: 1 }) expect(list.byKey('b')?.get()).toEqual({ id: 'b', value: 2 }) }) test('should use string prefix for auto-generated keys', () => { const list = createList([1, 2, 3], { keyConfig: 'item-' }) expect(list.keyAt(0)).toBe('item-0') expect(list.keyAt(1)).toBe('item-1') expect(list.keyAt(2)).toBe('item-2') }) }) describe('Iteration', () => { test('should support for...of via Symbol.iterator', () => { const list = createList([10, 20, 30]) const signals = [...list] expect(signals).toHaveLength(3) // biome-ignore lint/style/noNonNullAssertion: test expect(signals[0]!.get()).toBe(10) // biome-ignore lint/style/noNonNullAssertion: test expect(signals[1]!.get()).toBe(20) // biome-ignore lint/style/noNonNullAssertion: test expect(signals[2]!.get()).toBe(30) }) }) describe('Reactivity', () => { test('get() should trigger effects on structural changes', () => { const list = createList([1, 2, 3]) let lastArray: number[] = [] createEffect(() => { lastArray = list.get() }) expect(lastArray).toEqual([1, 2, 3]) list.add(4) expect(lastArray).toEqual([1, 2, 3, 4]) }) test('individual item signals should trigger effects', () => { const list = createList([{ count: 5 }]) let lastCount = 0 let effectCount = 0 createEffect(() => { lastCount = list.at(0)?.get().count ?? 0 effectCount++ }) expect(lastCount).toBe(5) expect(effectCount).toBe(1) list.at(0)?.set({ count: 10 }) expect(lastCount).toBe(10) expect(effectCount).toBe(2) }) test('computed signals should react to list changes', () => { const list = createList([1, 2, 3]) const sum = createMemo(() => list.get().reduce((acc, n) => acc + n, 0), ) expect(sum.get()).toBe(6) list.add(4) expect(sum.get()).toBe(10) list.remove(0) expect(sum.get()).toBe(9) }) }) describe('options.itemEquals', () => { test('should use DEEP_EQUALITY by default for object items', () => { const list = createList([{ a: 1 }, { a: 2 }]) // biome-ignore lint/style/noNonNullAssertion: test const item = list.at(0)! let count = 0 createEffect(() => { item.get() count++ }) expect(count).toBe(1) // biome-ignore lint/style/noNonNullAssertion: test list.replace(list.keyAt(0)!, { a: 1 }) expect(count).toBe(1) }) test('should allow custom itemEquals', () => { const list = createList([{ id: 1, val: 'a' }], { itemEquals: (a, b) => a.id === b.id, }) // biome-ignore lint/style/noNonNullAssertion: test const item = list.at(0)! let count = 0 createEffect(() => { item.get() count++ }) // biome-ignore lint/style/noNonNullAssertion: test list.replace(list.keyAt(0)!, { id: 1, val: 'b' }) expect(count).toBe(1) expect(item.get().val).toBe('a') }) }) describe('options.watched', () => { test('should call watched on first subscriber and cleanup on last unsubscribe', () => { let watchedCalled = false let unwatchedCalled = false const list = createList([10, 20], { watched: () => { watchedCalled = true return () => { unwatchedCalled = true } }, }) expect(watchedCalled).toBe(false) const dispose = createEffect(() => { list.get() }) expect(watchedCalled).toBe(true) expect(unwatchedCalled).toBe(false) dispose() expect(unwatchedCalled).toBe(true) }) test('should activate on length access', () => { let watchedCalled = false const list = createList([1, 2], { watched: () => { watchedCalled = true return () => {} }, }) const dispose = createEffect(() => { void list.length }) expect(watchedCalled).toBe(true) dispose() }) test('should activate watched via sync deriveCollection', () => { let watchedCalled = false let unwatchedCalled = false const list = createList([1, 2, 3], { watched: () => { watchedCalled = true return () => { unwatchedCalled = true } }, }) const derived = list.deriveCollection((v: number) => v * 2) expect(watchedCalled).toBe(false) const dispose = createEffect(() => { derived.get() }) expect(watchedCalled).toBe(true) expect(unwatchedCalled).toBe(false) dispose() expect(unwatchedCalled).toBe(true) }) test('should activate watched via async deriveCollection', async () => { let watchedCalled = false let unwatchedCalled = false const list = createList([1, 2, 3], { watched: () => { watchedCalled = true return () => { unwatchedCalled = true } }, }) const derived = list.deriveCollection( async (v: number, _abort: AbortSignal) => v * 2, ) expect(watchedCalled).toBe(false) const dispose = createEffect(() => { derived.get() }) expect(watchedCalled).toBe(true) await wait(10) expect(unwatchedCalled).toBe(false) dispose() expect(unwatchedCalled).toBe(true) }) test('should not tear down watched during list mutation via deriveCollection', () => { let activations = 0 let deactivations = 0 const list = createList([1, 2], { watched: () => { activations++ return () => { deactivations++ } }, }) const derived = list.deriveCollection((v: number) => v * 2) let result: number[] = [] const dispose = createEffect(() => { result = derived.get() }) expect(activations).toBe(1) expect(deactivations).toBe(0) expect(result).toEqual([2, 4]) // Add item — should NOT tear down and restart watched list.add(3) expect(result).toEqual([2, 4, 6]) expect(activations).toBe(1) expect(deactivations).toBe(0) // Remove item — should NOT tear down and restart watched list.remove(0) expect(activations).toBe(1) expect(deactivations).toBe(0) dispose() expect(deactivations).toBe(1) }) test('should delay watched activation for conditional reads', () => { let watchedCalled = false const list = createList([1, 2], { watched: () => { watchedCalled = true return () => {} }, }) const show = createState(false) const dispose = createScope(() => { createEffect(() => { if (show.get()) { list.get() } }) }) // Conditional read — list not accessed, watched should not fire expect(watchedCalled).toBe(false) // Flip condition — list is now accessed show.set(true) expect(watchedCalled).toBe(true) dispose() }) test('should activate watched via chained deriveCollection', () => { let watchedCalled = false const list = createList([1, 2, 3], { watched: () => { watchedCalled = true return () => {} }, }) const doubled = list.deriveCollection((v: number) => v * 2) const quadrupled = doubled.deriveCollection((v: number) => v * 2) expect(watchedCalled).toBe(false) const dispose = createEffect(() => { quadrupled.get() }) expect(watchedCalled).toBe(true) dispose() }) test('should activate watched via deriveCollection read inside match()', async () => { let watchedCalled = false const list = createList([1, 2], { watched: () => { watchedCalled = true return () => {} }, }) const derived = list.deriveCollection((v: number) => v * 10) const task = createTask(async () => { await wait(10) return 'done' }) const dispose = createScope(() => { createEffect(() => { // Read derived BEFORE match to ensure subscription const values = derived.get() match([task], { ok: () => { void values }, nil: () => {}, }) }) }) // watched should activate synchronously even though task is pending expect(watchedCalled).toBe(true) await wait(50) dispose() }) }) describe('Input Validation', () => { test('should throw for non-array initial value', () => { expect(() => { // @ts-expect-error - Testing invalid input createList('not an array') }).toThrow() }) test('should throw for null initial value', () => { expect(() => { // @ts-expect-error - Testing invalid input createList(null) }).toThrow() }) }) }) 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 list = createList([], { keyConfig: 'todo', createItem: createStore<TodoItem>, }) const byKey = list.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 > > })