@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
290 lines (248 loc) • 8.05 kB
text/typescript
import { describe, expect, test } from 'bun:test'
import { createEffect, createState, isMemo, isState } from '../index.ts'
/* === Tests === */
describe('State', () => {
describe('createState', () => {
test('should return initial value from get()', () => {
const count = createState(0)
expect(count.get()).toBe(0)
})
test('should work with different value types', () => {
expect(createState(false).get()).toBe(false)
expect(createState('foo').get()).toBe('foo')
expect(createState([1, 2, 3]).get()).toEqual([1, 2, 3])
expect(createState({ a: 1 }).get()).toEqual({ a: 1 })
})
test('should have Symbol.toStringTag of "State"', () => {
const state = createState(0)
expect(state[Symbol.toStringTag]).toBe('State')
})
})
describe('isState', () => {
test('should identify state signals', () => {
expect(isState(createState(0))).toBe(true)
})
test('should return false for non-state values', () => {
expect(isState(42)).toBe(false)
expect(isState(null)).toBe(false)
expect(isState({})).toBe(false)
expect(isMemo(createState(0))).toBe(false)
})
})
describe('set', () => {
test('should update value', () => {
const state = createState(0)
state.set(42)
expect(state.get()).toBe(42)
})
test('should replace value entirely for objects', () => {
const state = createState<Record<string, unknown>>({ a: 1 })
state.set({ b: 2 })
expect(state.get()).toEqual({ b: 2 })
})
test('should replace value entirely for arrays', () => {
const state = createState([1, 2, 3])
state.set([4, 5, 6])
expect(state.get()).toEqual([4, 5, 6])
})
test('should skip update when value is equal by reference', () => {
const obj = { a: 1 }
const state = createState(obj)
let effectCount = 0
createEffect(() => {
state.get()
effectCount++
})
expect(effectCount).toBe(1)
state.set(obj) // same reference
expect(effectCount).toBe(1)
})
})
describe('update', () => {
test('should update value via callback', () => {
const state = createState(0)
state.update(v => v + 1)
expect(state.get()).toBe(1)
})
test('should pass current value to callback', () => {
const state = createState('hello')
state.update(v => v.toUpperCase())
expect(state.get()).toBe('HELLO')
})
test('should work with arrays', () => {
const state = createState([1, 2, 3])
state.update(arr => [...arr, 4])
expect(state.get()).toEqual([1, 2, 3, 4])
})
test('should work with objects', () => {
const state = createState({ count: 0 })
state.update(obj => ({ ...obj, count: obj.count + 1 }))
expect(state.get()).toEqual({ count: 1 })
})
})
describe('options.equals', () => {
test('should use custom equality function to skip updates', () => {
const state = createState(
{ x: 1 },
{ equals: (a, b) => a.x === b.x },
)
let effectCount = 0
createEffect(() => {
state.get()
effectCount++
})
expect(effectCount).toBe(1)
state.set({ x: 1 }) // structurally equal
expect(effectCount).toBe(1)
state.set({ x: 2 }) // different
expect(effectCount).toBe(2)
})
test('should default to reference equality', () => {
const state = createState({ x: 1 })
let effectCount = 0
createEffect(() => {
state.get()
effectCount++
})
expect(effectCount).toBe(1)
state.set({ x: 1 }) // new reference, same shape
expect(effectCount).toBe(2)
})
})
describe('options.guard', () => {
test('should validate initial value against guard', () => {
expect(() => {
createState(0, {
guard: (v): v is number => typeof v === 'number' && v > 0,
})
}).toThrow('[State] Signal value 0 is invalid')
})
test('should validate set() values against guard', () => {
const state = createState(1, {
guard: (v): v is number => typeof v === 'number' && v > 0,
})
expect(() => state.set(0)).toThrow(
'[State] Signal value 0 is invalid',
)
expect(state.get()).toBe(1) // unchanged
})
test('should validate update() return values against guard', () => {
const state = createState(1, {
guard: (v): v is number => typeof v === 'number' && v > 0,
})
expect(() => state.update(() => 0)).toThrow(
'[State] Signal value 0 is invalid',
)
expect(state.get()).toBe(1) // unchanged
})
test('should allow values that pass the guard', () => {
const state = createState(1, {
guard: (v): v is number => typeof v === 'number' && v > 0,
})
state.set(5)
expect(state.get()).toBe(5)
})
})
describe('Edge cases: NaN and special numbers', () => {
test('should propagate on every set(NaN) since NaN !== NaN', () => {
const state = createState(NaN)
let effectCount = 0
createEffect(() => {
state.get()
effectCount++
})
expect(effectCount).toBe(1)
state.set(NaN)
expect(effectCount).toBe(2) // NaN !== NaN, so it propagates
state.set(NaN)
expect(effectCount).toBe(3)
})
test('should reject NaN with a Number.isFinite guard', () => {
const state = createState(1, {
guard: (v): v is number => Number.isFinite(v),
})
expect(() => state.set(NaN)).toThrow(
'[State] Signal value NaN is invalid',
)
expect(state.get()).toBe(1)
})
test('should reject Infinity with a Number.isFinite guard', () => {
const state = createState(1, {
guard: (v): v is number => Number.isFinite(v),
})
expect(() => state.set(Infinity)).toThrow(
'[State] Signal value Infinity is invalid',
)
expect(() => state.set(-Infinity)).toThrow(
'[State] Signal value -Infinity is invalid',
)
expect(state.get()).toBe(1)
})
test('should treat +0 and -0 as equal by default (===)', () => {
const state = createState(0)
let effectCount = 0
createEffect(() => {
state.get()
effectCount++
})
expect(effectCount).toBe(1)
state.set(-0) // +0 === -0 is true
expect(effectCount).toBe(1) // no propagation
})
})
describe('Input Validation', () => {
test('should throw NullishSignalValueError for null or undefined initial value', () => {
expect(() => {
// @ts-expect-error - Testing invalid input
createState(null)
}).toThrow('[State] Signal value cannot be null or undefined')
expect(() => {
// @ts-expect-error - Testing invalid input
createState(undefined)
}).toThrow('[State] Signal value cannot be null or undefined')
})
test('should throw NullishSignalValueError for null or undefined in set()', () => {
const state = createState(42)
expect(() => {
// @ts-expect-error - Testing invalid input
state.set(null)
}).toThrow('[State] Signal value cannot be null or undefined')
expect(() => {
// @ts-expect-error - Testing invalid input
state.set(undefined)
}).toThrow('[State] Signal value cannot be null or undefined')
})
test('should throw NullishSignalValueError for nullish return from update()', () => {
const state = createState(42)
expect(() => {
// @ts-expect-error - Testing invalid return value
state.update(() => null)
}).toThrow('[State] Signal value cannot be null or undefined')
expect(() => {
// @ts-expect-error - Testing invalid return value
state.update(() => undefined)
}).toThrow('[State] Signal value cannot be null or undefined')
expect(state.get()).toBe(42) // unchanged
})
test('should throw InvalidCallbackError for non-function in update()', () => {
const state = createState(42)
expect(() => {
// @ts-expect-error - Testing invalid input
state.update(null)
}).toThrow('[State] Callback null is invalid')
expect(() => {
// @ts-expect-error - Testing invalid input
state.update('not a function')
}).toThrow('[State] Callback "not a function" is invalid')
})
test('should propagate errors thrown by update callback', () => {
const state = createState(42)
expect(() => {
state.update(() => {
throw new Error('Updater error')
})
}).toThrow('Updater error')
expect(state.get()).toBe(42) // unchanged
})
})
})