@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
575 lines (502 loc) • 13.3 kB
text/typescript
import { describe, expect, test } from 'bun:test'
import {
batch,
createEffect,
createMemo,
createScope,
createState,
isMemo,
isState,
UnsetSignalValueError,
} from '../index.ts'
/* === Tests === */
describe('Memo', () => {
describe('createMemo', () => {
test('should compute a derived value', () => {
const derived = createMemo(() => 1 + 2)
expect(derived.get()).toBe(3)
})
test('should have Symbol.toStringTag of "Memo"', () => {
const memo = createMemo(() => 0)
expect(memo[Symbol.toStringTag]).toBe('Memo')
})
test('should evaluate lazily on first get()', () => {
let computed = false
const memo = createMemo(() => {
computed = true
return 42
})
expect(computed).toBe(false)
memo.get()
expect(computed).toBe(true)
})
test('should throw UnsetSignalValueError if callback returns undefined', () => {
const memo = createMemo(() => undefined as unknown as number)
expect(() => memo.get()).toThrow(UnsetSignalValueError)
})
})
describe('isMemo', () => {
test('should identify memo signals', () => {
expect(isMemo(createMemo(() => 0))).toBe(true)
})
test('should return false for non-memo values', () => {
expect(isMemo(42)).toBe(false)
expect(isMemo(null)).toBe(false)
expect(isMemo({})).toBe(false)
expect(isState(createMemo(() => 0))).toBe(false)
})
})
describe('Dependency Tracking', () => {
test('should recompute when a dependency changes', () => {
const source = createState(42)
const derived = createMemo(() => source.get() + 1)
expect(derived.get()).toBe(43)
source.set(24)
expect(derived.get()).toBe(25)
})
test('should track through a chain of memos', () => {
const x = createState(42)
const a = createMemo(() => x.get() + 1)
const b = createMemo(() => a.get() * 2)
const c = createMemo(() => b.get() + 1)
expect(c.get()).toBe(87)
x.set(24)
expect(c.get()).toBe(51)
})
test('should recompute after multiple state changes', () => {
const a = createState(3)
const b = createState(4)
let count = 0
const sum = createMemo(() => {
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)
})
})
describe('Memoization', () => {
test('should skip downstream recomputation when result is unchanged', () => {
let count = 0
const x = createState('a')
const a = createMemo(() => {
x.get()
return 'foo'
})
const b = createMemo(() => {
count++
return a.get()
})
expect(b.get()).toBe('foo')
expect(count).toBe(1)
x.set('aa')
x.set('aaa')
expect(b.get()).toBe('foo')
expect(count).toBe(1)
})
test('should not propagate when intermediate result is unchanged', () => {
let count = 0
const x = createState(42)
const a = createMemo(() => x.get() % 2)
const b = createMemo(() => (a.get() ? 'odd' : 'even'))
const c = createMemo(() => {
count++
return `c: ${b.get()}`
})
expect(c.get()).toBe('c: even')
expect(count).toBe(1)
x.set(44)
x.set(46)
expect(c.get()).toBe('c: even')
expect(count).toBe(1)
})
})
describe('Diamond Graph', () => {
test('should compute each memo only once', () => {
let count = 0
const x = createState('a')
const a = createMemo(() => x.get())
const b = createMemo(() => x.get())
const c = createMemo(() => {
count++
return `${a.get()} ${b.get()}`
})
expect(c.get()).toBe('a a')
expect(count).toBe(1)
x.set('aa')
expect(c.get()).toBe('aa aa')
expect(count).toBe(2)
})
test('should compute each memo only once with tail', () => {
let count = 0
const x = createState('a')
const a = createMemo(() => x.get())
const b = createMemo(() => x.get())
const c = createMemo(() => `${a.get()} ${b.get()}`)
const d = createMemo(() => {
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 drop X->B->X updates', () => {
let count = 0
const x = createState(2)
const a = createMemo(() => x.get() - 1)
const b = createMemo(() => x.get() + a.get())
const c = createMemo(() => {
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)
})
})
describe('Error Handling', () => {
test('should detect and throw for circular dependencies', () => {
const a = createState(1)
const b = createMemo(() => c.get() + 1)
const c = createMemo((): number => b.get() + a.get())
expect(() => b.get()).toThrow('[Memo] Circular dependency detected')
})
test('should propagate errors from computation', () => {
const x = createState(0)
const a = createMemo(() => {
if (x.get() === 1) throw new Error('Computation failed')
return 1
})
expect(a.get()).toBe(1)
x.set(1)
expect(() => a.get()).toThrow('Computation failed')
})
test('should allow downstream memos to recover from errors', () => {
const x = createState(0)
let errCount = 0
const a = createMemo(() => {
if (x.get() === 1) throw new Error('Computation failed')
return 1
})
const b = createMemo(() => {
try {
return `ok: ${a.get()}`
} catch (_e) {
errCount++
return 'recovered'
}
})
expect(b.get()).toBe('ok: 1')
x.set(1)
expect(b.get()).toBe('recovered')
expect(errCount).toBe(1)
x.set(0)
expect(b.get()).toBe('ok: 1')
})
})
describe('options.value (prev)', () => {
test('should pass initial value as prev to first computation', () => {
let receivedPrev: number | undefined
const memo = createMemo(
prev => {
receivedPrev = prev
return prev + 1
},
{ value: 10 },
)
expect(memo.get()).toBe(11)
expect(receivedPrev).toBe(10)
})
test('should pass undefined as prev when no initial value', () => {
let receivedPrev: unknown = 999
const memo = createMemo((prev: number | undefined) => {
receivedPrev = prev
return 42
})
memo.get()
expect(receivedPrev).toBeUndefined()
})
test('should pass previous computed value on recomputation', () => {
const source = createState(5)
let receivedPrev: number | undefined
const memo = createMemo(
prev => {
receivedPrev = prev
return source.get() * 2
},
{ value: 0 },
)
expect(memo.get()).toBe(10)
expect(receivedPrev).toBe(0)
source.set(3)
expect(memo.get()).toBe(6)
expect(receivedPrev).toBe(10)
})
test('should work as a reducer', () => {
const increment = createState(0)
const sum = createMemo(
prev => {
const inc = increment.get()
return inc === 0 ? prev : prev + inc
},
{ value: 0 },
)
expect(sum.get()).toBe(0)
increment.set(5)
expect(sum.get()).toBe(5)
increment.set(3)
expect(sum.get()).toBe(8)
})
test('should preserve prev value across errors', () => {
const shouldError = createState(false)
const counter = createState(1)
const memo = createMemo(
prev => {
if (shouldError.get()) throw new Error('fail')
return prev + counter.get()
},
{ value: 10 },
)
expect(memo.get()).toBe(11) // 10 + 1
counter.set(5)
expect(memo.get()).toBe(16) // 11 + 5
shouldError.set(true)
expect(() => memo.get()).toThrow('fail')
shouldError.set(false)
counter.set(2)
expect(memo.get()).toBe(18) // 16 + 2
})
})
describe('options.equals', () => {
test('should use custom equality to skip propagation', () => {
const source = createState(1)
let downstream = 0
const memo = createMemo(() => ({ x: source.get() % 2 }), {
value: { x: -1 },
equals: (a, b) => a.x === b.x,
})
const tail = createMemo(() => {
downstream++
return memo.get()
})
tail.get()
expect(downstream).toBe(1)
source.set(3) // still odd, structurally equal
tail.get()
expect(downstream).toBe(1)
source.set(2) // now even, different
tail.get()
expect(downstream).toBe(2)
})
})
describe('options.guard', () => {
test('should validate initial value against guard', () => {
expect(() => {
createMemo(() => 42, {
value: -1,
guard: (v): v is number => typeof v === 'number' && v >= 0,
})
}).toThrow('[Memo] Signal value -1 is invalid')
})
test('should accept initial value that passes guard', () => {
const memo = createMemo(prev => prev + 1, {
value: 0,
guard: (v): v is number => typeof v === 'number' && v >= 0,
})
expect(memo.get()).toBe(1)
})
})
describe('Input Validation', () => {
test('should throw InvalidCallbackError for non-function callback', () => {
// @ts-expect-error - Testing invalid input
expect(() => createMemo(null)).toThrow(
'[Memo] Callback null is invalid',
)
// @ts-expect-error - Testing invalid input
expect(() => createMemo(42)).toThrow(
'[Memo] Callback 42 is invalid',
)
// @ts-expect-error - Testing invalid input
expect(() => createMemo('str')).toThrow(
'[Memo] Callback "str" is invalid',
)
})
test('should throw InvalidCallbackError for async callback', () => {
expect(() => createMemo(async () => 42)).toThrow()
})
test('should throw NullishSignalValueError for null initial value', () => {
expect(() => {
// @ts-expect-error - Testing invalid input
createMemo(() => 42, { value: null })
}).toThrow('[Memo] Signal value cannot be null or undefined')
})
})
describe('options.watched', () => {
test('should call watched on first effect access', () => {
let watchedCount = 0
const externalValue = 1
const memo = createMemo(() => externalValue, {
value: 0,
watched: _invalidate => {
watchedCount++
return () => {}
},
})
expect(watchedCount).toBe(0)
const dispose = createScope(() => {
createEffect(() => {
void memo.get()
})
})
expect(watchedCount).toBe(1)
dispose()
})
test('should call cleanup when last effect stops watching', () => {
let cleanedUp = false
const externalValue = 1
const memo = createMemo(() => externalValue, {
value: 0,
watched: _invalidate => {
return () => {
cleanedUp = true
}
},
})
const dispose = createScope(() => {
createEffect(() => {
void memo.get()
})
})
expect(cleanedUp).toBe(false)
dispose()
expect(cleanedUp).toBe(true)
})
test('should recompute memo when invalidate is called', () => {
let externalValue = 10
let computeCount = 0
let invalidate!: () => void
const memo = createMemo(
() => {
computeCount++
return externalValue
},
{
value: 0,
watched: inv => {
invalidate = inv
return () => {}
},
},
)
let observed = 0
const dispose = createScope(() => {
createEffect(() => {
observed = memo.get()
})
})
expect(observed).toBe(10)
expect(computeCount).toBe(1)
externalValue = 20
invalidate()
expect(observed).toBe(20)
expect(computeCount).toBe(2)
dispose()
})
test('should defer flush when invalidate is called inside batch', () => {
let externalValue = 1
let invalidate!: () => void
const memo = createMemo(() => externalValue, {
value: 0,
watched: inv => {
invalidate = inv
return () => {}
},
})
let observed = 0
const dispose = createScope(() => {
createEffect(() => {
observed = memo.get()
})
})
expect(observed).toBe(1)
batch(() => {
externalValue = 2
invalidate()
expect(observed).toBe(1) // not yet flushed
})
expect(observed).toBe(2) // flushed after batch
dispose()
})
test('should re-activate watched after cleanup and new effect access', () => {
let watchedCount = 0
const externalValue = 1
const memo = createMemo(() => externalValue, {
value: 0,
watched: _invalidate => {
watchedCount++
return () => {}
},
})
const dispose1 = createScope(() => {
createEffect(() => {
void memo.get()
})
})
expect(watchedCount).toBe(1)
dispose1()
const dispose2 = createScope(() => {
createEffect(() => {
void memo.get()
})
})
expect(watchedCount).toBe(2)
dispose2()
})
test('should work with both tracked dependencies and watched', () => {
const source = createState(1)
let externalValue = 100
let computeCount = 0
let invalidate!: () => void
const memo = createMemo(
() => {
computeCount++
return source.get() + externalValue
},
{
value: 0,
watched: inv => {
invalidate = inv
return () => {}
},
},
)
let observed = 0
const dispose = createScope(() => {
createEffect(() => {
observed = memo.get()
})
})
expect(observed).toBe(101)
expect(computeCount).toBe(1)
// Tracked dependency triggers recomputation
source.set(2)
expect(observed).toBe(102)
expect(computeCount).toBe(2)
// External invalidation triggers recomputation
externalValue = 200
invalidate()
expect(observed).toBe(202)
expect(computeCount).toBe(3)
dispose()
})
})
})