@zeix/cause-effect
Version:
Cause & Effect - reactive state management with signals.
437 lines (396 loc) • 10.9 kB
text/typescript
import { describe, test, expect } from 'bun:test'
import { state, computed, UNSET, isComputed, isState } 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 = count.map(v => v * 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 derived = state(42).map(v => ++v)
expect(derived.get()).toBe(43)
})
test('should compute function dependent on an updated signal', function() {
const cause = state(42)
const derived = cause.map(v => ++v)
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 = promised.map(increment)
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 = promised.map(increment)
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 async 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({
signals: [a, b],
ok: (aValue, bValue) => 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 derived = state(42)
.map(v => ++v)
.map(v => v * 2)
.map(v => ++v)
expect(derived.get()).toBe(87)
})
test('should compute function dependent on a chain of computed states dependent on an updated signal', function() {
const cause = state(42)
const derived = cause
.map(v => ++v)
.map(v => v * 2)
.map(v => ++v)
cause.set(24)
expect(derived.get()).toBe(51)
})
test('should drop X->B->X updates', function () {
let count = 0
const x = state(2)
const a = x.map(v => --v)
const b = computed(() => x.get() + a.get())
const c = b.map(v => {
count++
return 'c: ' + v
})
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 = x.map(v => v)
const b = x.map(v => v)
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 = x.map(v => v)
const b = x.map(v => v)
const c = computed(() => a.get() + ' ' + b.get())
const d = c.map(v => {
count++
return v
})
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 b = x.map(() => 'foo').map(v => {
count++
return v
})
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 c = x.map(v => v % 2)
.map(v => v ? 'odd' : 'even')
.map(v => {
count++
return `c: ${v}`
})
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 = x.map(v => {
if (v === 1) throw new Error('Calculation error')
return 1
})
const c = computed({
signals: [a],
ok: v => v ? 'success' : 'failure',
err: () => {
errCount++
return 'recovered'
},
}).map(v => {
okCount++
return `c: ${v}`
})
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 return a computed signal with .map()', function() {
const cause = state(42)
const derived = cause.map(v => ++v)
const double = derived.map(v => v * 2)
expect(isComputed(double)).toBe(true)
expect(double.get()).toBe(86)
})
test('should create an effect that reacts on async computed changes with .tap()', 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
derived.tap({
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 = toggleState.map(v => {
if (v) 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({
signals: [errorProne, asyncValue],
ok: v => {
okCount++
return v
},
nil: () => {
nilCount++
return 0
},
err: () => {
errCount++
return -1
}
})
/* 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)
result = complexComputed.get()
// console.log(`i: ${i}, result: ${result}`)
}
expect(nilCount).toBeGreaterThanOrEqual(5)
expect(okCount).toBeGreaterThanOrEqual(2)
expect(errCount).toBeGreaterThanOrEqual(3)
expect(okCount + errCount + nilCount).toBe(10)
})
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 startTime = performance.now()
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
})
/* derived.tap({
ok: v => {
console.log(`ok: ${v}, time: ${performance.now() - startTime}ms`)
},
nil: () => {
console.warn(`nil, time: ${performance.now() - startTime}ms`)
},
err: e => {
console.error(`err: ${e.message}, time: ${performance.now() - startTime}ms`)
}
}) */
// 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)
})
})