UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management with signals.

422 lines (382 loc) 10.6 kB
import { describe, test, expect } from 'bun:test' import { state, computed, UNSET, isComputed, isState, effect, } from '../index.ts' /* === Utility Functions === */ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const increment = (n: number) => (Number.isFinite(n) ? n + 1 : UNSET) /* === Tests === */ describe('Computed', function () { test('should identify computed signals with isComputed()', () => { const count = state(42) const doubled = computed(() => count.get() * 2) expect(isComputed(doubled)).toBe(true) expect(isState(doubled)).toBe(false) }) test('should compute a function', function () { const derived = computed(() => 1 + 2) expect(derived.get()).toBe(3) }) test('should compute function dependent on a signal', function () { const cause = state(42) const derived = computed(() => cause.get() + 1) expect(derived.get()).toBe(43) }) test('should compute function dependent on an updated signal', function () { const cause = state(42) const derived = computed(() => cause.get() + 1) cause.set(24) expect(derived.get()).toBe(25) }) test('should compute function dependent on an async signal', async function () { const status = state('pending') const promised = computed(async () => { await wait(100) status.set('success') return 42 }) const derived = computed(() => increment(promised.get())) expect(derived.get()).toBe(UNSET) expect(status.get()).toBe('pending') await wait(110) expect(derived.get()).toBe(43) expect(status.get()).toBe('success') }) test('should handle errors from an async signal gracefully', async function () { const status = state('pending') const error = state('') const promised = computed(async () => { await wait(100) status.set('error') error.set('error occurred') return 0 }) const derived = computed(() => increment(promised.get())) expect(derived.get()).toBe(UNSET) expect(status.get()).toBe('pending') await wait(110) expect(error.get()).toBe('error occurred') expect(status.get()).toBe('error') }) test('should compute task signals in parallel without waterfalls', async function () { const a = computed(async () => { await wait(100) return 10 }) const b = computed(async () => { await wait(100) return 20 }) const c = computed(() => { const aValue = a.get() const bValue = b.get() return aValue === UNSET || bValue === UNSET ? UNSET : aValue + bValue }) expect(c.get()).toBe(UNSET) await wait(110) expect(c.get()).toBe(30) }) test('should compute function dependent on a chain of computed states dependent on a signal', function () { const x = state(42) const a = computed(() => x.get() + 1) const b = computed(() => a.get() * 2) const c = computed(() => b.get() + 1) expect(c.get()).toBe(87) }) test('should compute function dependent on a chain of computed states dependent on an updated signal', function () { const x = state(42) const a = computed(() => x.get() + 1) const b = computed(() => a.get() * 2) const c = computed(() => b.get() + 1) x.set(24) expect(c.get()).toBe(51) }) test('should drop X->B->X updates', function () { let count = 0 const x = state(2) const a = computed(() => x.get() - 1) const b = computed(() => x.get() + a.get()) const c = computed(() => { count++ return 'c: ' + b.get() }) expect(c.get()).toBe('c: 3') expect(count).toBe(1) x.set(4) expect(c.get()).toBe('c: 7') expect(count).toBe(2) }) test('should only update every signal once (diamond graph)', function () { let count = 0 const x = state('a') const a = computed(() => x.get()) const b = computed(() => x.get()) const c = computed(() => { count++ return a.get() + ' ' + b.get() }) expect(c.get()).toBe('a a') expect(count).toBe(1) x.set('aa') // flush() expect(c.get()).toBe('aa aa') expect(count).toBe(2) }) test('should only update every signal once (diamond graph + tail)', function () { let count = 0 const x = state('a') const a = computed(() => x.get()) const b = computed(() => x.get()) const c = computed(() => a.get() + ' ' + b.get()) const d = computed(() => { count++ return c.get() }) expect(d.get()).toBe('a a') expect(count).toBe(1) x.set('aa') expect(d.get()).toBe('aa aa') expect(count).toBe(2) }) test('should update multiple times after multiple state changes', function () { const a = state(3) const b = state(4) let count = 0 const sum = computed(() => { count++ return a.get() + b.get() }) expect(sum.get()).toBe(7) a.set(6) expect(sum.get()).toBe(10) b.set(8) expect(sum.get()).toBe(14) expect(count).toBe(3) }) /* * Note for the next two tests: * * Due to the lazy evaluation strategy, unchanged computed signals may propagate * change notifications one additional time before stabilizing. This is a * one-time performance cost that allows for efficient memoization and * error handling in most cases. */ test('should bail out if result is the same', function () { let count = 0 const x = state('a') const a = computed(() => { x.get() return 'foo' }) const b = computed(() => { count++ return a.get() }) expect(b.get()).toBe('foo') expect(count).toBe(1) x.set('aa') x.set('aaa') x.set('aaaa') expect(b.get()).toBe('foo') expect(count).toBe(2) }) test('should block if result remains unchanged', function () { let count = 0 const x = state(42) const a = computed(() => x.get() % 2) const b = computed(() => (a.get() ? 'odd' : 'even')) const c = computed(() => { count++ return `c: ${b.get()}` }) expect(c.get()).toBe('c: even') expect(count).toBe(1) x.set(44) x.set(46) x.set(48) expect(c.get()).toBe('c: even') expect(count).toBe(2) }) test('should detect and throw error for circular dependencies', function () { const a = state(1) const b = computed(() => c.get() + 1) const c = computed(() => b.get() + a.get()) expect(() => { b.get() // This should trigger the circular dependency }).toThrow('Circular dependency in computed detected') expect(a.get()).toBe(1) }) test('should propagate error if an error occurred', function () { let okCount = 0 let errCount = 0 const x = state(0) const a = computed(() => { if (x.get() === 1) throw new Error('Calculation error') return 1 }) // Replace matcher with try/catch in a computed const b = computed(() => { try { a.get() // just check if it works return `c: success` } catch (_error) { errCount++ return `c: recovered` } }) const c = computed(() => { okCount++ return b.get() }) expect(a.get()).toBe(1) expect(c.get()).toBe('c: success') expect(okCount).toBe(1) try { x.set(1) expect(a.get()).toBe(1) expect(true).toBe(false) // This line should not be reached } catch (error) { expect(error.message).toBe('Calculation error') } finally { expect(c.get()).toBe('c: recovered') expect(okCount).toBe(2) expect(errCount).toBe(1) } }) test('should create an effect that reacts on async computed changes', async function () { const cause = state(42) const derived = computed(async () => { await wait(100) return cause.get() + 1 }) let okCount = 0 let nilCount = 0 let result: number = 0 effect({ signals: [derived], ok: v => { result = v okCount++ }, nil: () => { nilCount++ }, }) cause.set(43) expect(okCount).toBe(0) expect(nilCount).toBe(1) expect(result).toBe(0) await wait(110) expect(okCount).toBe(1) // not +1 because initial state never made it here expect(nilCount).toBe(1) expect(result).toBe(44) }) test('should handle complex computed signal with error and async dependencies', async function () { const toggleState = state(true) const errorProne = computed(() => { if (toggleState.get()) throw new Error('Intentional error') return 42 }) const asyncValue = computed(async () => { await wait(50) return 10 }) let okCount = 0 let nilCount = 0 let errCount = 0 // let _result: number = 0 const complexComputed = computed(() => { try { const x = errorProne.get() const y = asyncValue.get() if (y === UNSET) { // not ready yet nilCount++ return 0 } else { // happy path okCount++ return x + y } } catch (_error) { // error path errCount++ return -1 } }) for (let i = 0; i < 10; i++) { toggleState.set(!!(i % 2)) await wait(10) complexComputed.get() } // Adjusted expectations to be more flexible expect(nilCount + okCount + errCount).toBe(10) expect(okCount).toBeGreaterThan(0) expect(errCount).toBeGreaterThan(0) }) test('should handle signal changes during async computation', async function () { const source = state(1) let computationCount = 0 const derived = computed(async abort => { computationCount++ expect(abort?.aborted).toBe(false) await wait(100) return source.get() }) // Start first computation expect(derived.get()).toBe(UNSET) expect(computationCount).toBe(1) // Change source before first computation completes source.set(2) await wait(210) expect(derived.get()).toBe(2) expect(computationCount).toBe(1) }) test('should handle multiple rapid changes during async computation', async function () { const source = state(1) let computationCount = 0 const derived = computed(async abort => { computationCount++ expect(abort?.aborted).toBe(false) await wait(100) return source.get() }) // Start first computation expect(derived.get()).toBe(UNSET) expect(computationCount).toBe(1) // Make multiple rapid changes source.set(2) source.set(3) source.set(4) await wait(210) // Should have computed twice (initial + final change) expect(derived.get()).toBe(4) expect(computationCount).toBe(1) }) test('should handle errors in aborted computations', async function () { const source = state(1) const derived = computed(async () => { await wait(100) const value = source.get() if (value === 2) throw new Error('Intentional error') return value }) // Start first computation expect(derived.get()).toBe(UNSET) // Change to error state before first computation completes source.set(2) await wait(110) expect(() => derived.get()).toThrow('Intentional error') // Change to normal state before second computation completes source.set(3) await wait(100) expect(derived.get()).toBe(3) }) })