@zeix/cause-effect
Version:
Cause & Effect - reactive state management with signals.
422 lines (382 loc) • 10.6 kB
text/typescript
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)
})
})