UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management primitives library for TypeScript.

561 lines (489 loc) 14.8 kB
import { describe, expect, test } from 'bun:test' import { createEffect, createMemo, createState, createStore, isList, isStore, } from '../index.ts' describe('Store', () => { describe('createStore', () => { test('should create a store with initial values', () => { const user = createStore({ name: 'Hannah', email: 'hannah@example.com', }) expect(user.name.get()).toBe('Hannah') expect(user.email.get()).toBe('hannah@example.com') }) test('should create nested stores for object properties', () => { const user = createStore({ name: 'Alice', preferences: { theme: 'light', notifications: true }, }) expect(isStore(user.preferences)).toBe(true) expect(user.preferences.theme.get()).toBe('light') expect(user.preferences.notifications.get()).toBe(true) }) test('should create lists for array properties', () => { const data = createStore({ tags: ['a', 'b', 'c'] }) expect(isList(data.tags)).toBe(true) expect(data.tags.get()).toEqual(['a', 'b', 'c']) }) test('should handle deeply nested objects', () => { const config = createStore({ ui: { theme: { colors: { primary: '#007acc' } } }, }) expect(config.ui.theme.colors.primary.get()).toBe('#007acc') config.ui.theme.colors.primary.set('#ff6600') expect(config.ui.theme.colors.primary.get()).toBe('#ff6600') }) test('should handle empty initial value', () => { const empty = createStore({}) expect(empty.get()).toEqual({}) }) test('should have Symbol.toStringTag of "Store"', () => { const store = createStore({ a: 1 }) expect(store[Symbol.toStringTag]).toBe('Store') }) test('should have Symbol.isConcatSpreadable set to false', () => { const store = createStore({ a: 1 }) expect(store[Symbol.isConcatSpreadable]).toBe(false) }) }) describe('isStore', () => { test('should return true for store instances', () => { const store = createStore({ a: 1 }) expect(isStore(store)).toBe(true) }) test('should return false for non-store values', () => { expect(isStore(createState(1))).toBe(false) expect(isStore(createMemo(() => 1))).toBe(false) expect(isStore({})).toBe(false) expect(isStore(null)).toBe(false) }) }) describe('get', () => { test('should return the complete store value', () => { const user = createStore({ name: 'Alice', email: 'alice@example.com', }) expect(user.get()).toEqual({ name: 'Alice', email: 'alice@example.com', }) }) test('should return updated value after property changes', () => { const user = createStore({ name: 'Alice', age: 30 }) user.name.set('Bob') expect(user.get()).toEqual({ name: 'Bob', age: 30 }) }) }) describe('set', () => { test('should replace entire store value', () => { const user = createStore({ name: 'John', email: 'john@example.com', }) user.set({ name: 'Jane', email: 'jane@example.com' }) expect(user.name.get()).toBe('Jane') expect(user.email.get()).toBe('jane@example.com') }) test('should diff and apply granular changes', () => { const user = createStore({ name: 'John', age: 25 }) let nameRuns = 0 let ageRuns = 0 createEffect(() => { user.name.get() nameRuns++ }) createEffect(() => { user.age.get() ageRuns++ }) expect(nameRuns).toBe(1) expect(ageRuns).toBe(1) // Only change age — name effect should not re-run user.set({ name: 'John', age: 26 }) expect(nameRuns).toBe(1) expect(ageRuns).toBe(2) }) test('should not propagate when value is identical', () => { const store = createStore({ x: 1 }) let runs = 0 createEffect(() => { store.get() runs++ }) expect(runs).toBe(1) store.set({ x: 1 }) expect(runs).toBe(1) }) }) describe('update', () => { test('should modify store using callback', () => { const user = createStore({ name: 'John', age: 25 }) user.update(u => ({ ...u, age: u.age + 1 })) expect(user.name.get()).toBe('John') expect(user.age.get()).toBe(26) }) }) describe('add', () => { test('should add a new property', () => { const user = createStore<{ name: string; email?: string }>({ name: 'John', }) user.add('email', 'john@example.com') expect(user.email?.get()).toBe('john@example.com') expect(user.byKey('email')?.get()).toBe('john@example.com') }) test('should throw DuplicateKeyError for existing key', () => { const user = createStore({ name: 'John' }) expect(() => user.add('name', 'Jane')).toThrow() }) test('should be reactive', () => { const store = createStore<{ name: string; email?: string }>({ name: 'John', }) let lastValue: { name: string; email?: string } = { name: '' } createEffect(() => { lastValue = store.get() }) expect(lastValue).toEqual({ name: 'John' }) store.add('email', 'john@example.com') expect(lastValue).toEqual({ name: 'John', email: 'john@example.com', }) }) }) describe('remove', () => { test('should remove an existing property', () => { const user = createStore<{ name: string; email?: string }>({ name: 'John', email: 'john@example.com', }) user.remove('email') expect(user.byKey('email')).toBeUndefined() expect(user.email).toBeUndefined() }) test('should handle non-existent key gracefully', () => { const store = createStore({ name: 'John' }) expect(() => store.remove('nonexistent')).not.toThrow() }) test('should be reactive', () => { const store = createStore({ name: 'John', email: 'john@example.com', }) let lastValue: { name: string; email?: string } = { name: '', email: '', } let runs = 0 createEffect(() => { lastValue = store.get() runs++ }) expect(runs).toBe(1) store.remove('email') expect(lastValue).toEqual({ name: 'John' }) expect(runs).toBe(2) }) }) describe('byKey', () => { test('should return the signal for a property', () => { const user = createStore({ name: 'Alice', age: 30 }) const nameSignal = user.byKey('name') expect(nameSignal?.get()).toBe('Alice') expect(nameSignal).toBe(user.name) }) test('should return nested store for object property', () => { const app = createStore({ config: { version: '1.0.0' } }) const configStore = app.byKey('config') expect(isStore(configStore)).toBe(true) expect(configStore).toBe(app.config) }) test('should return undefined for non-existent key', () => { const store = createStore({ name: 'Alice' }) // @ts-expect-error deliberate access for nonexistent key expect(store.byKey('nonexistent')).toBeUndefined() }) }) describe('keys', () => { test('should return an iterator of property keys', () => { const store = createStore({ alpha: 1, beta: 2, gamma: 3 }) expect(Array.from(store.keys())).toEqual(['alpha', 'beta', 'gamma']) }) test('should reflect additions and removals', () => { const store = createStore<{ a: number; b?: number }>({ a: 1 }) expect(Array.from(store.keys())).toEqual(['a']) store.add('b', 2) expect(Array.from(store.keys())).toEqual(['a', 'b']) store.remove('b') expect(Array.from(store.keys())).toEqual(['a']) }) }) describe('Proxy Behavior', () => { test('should access properties directly as signals', () => { const user = createStore({ name: 'John', age: 30 }) expect(user.name.get()).toBe('John') expect(user.age.get()).toBe(30) user.name.set('Alicia') expect(user.name.get()).toBe('Alicia') }) test('should return undefined for non-existent properties', () => { const user = createStore({ name: 'Alice' }) // @ts-expect-error accessing non-existent property expect(user.nonexistent).toBeUndefined() }) test('should support "in" operator', () => { const user = createStore({ name: 'Alice' }) expect('name' in user).toBe(true) expect('email' in user).toBe(false) }) test('should support Object.keys()', () => { const user = createStore({ name: 'Alice', email: 'alice@example.com', }) expect(Object.keys(user).sort()).toEqual(['email', 'name']) }) test('should support for...in enumeration', () => { const user = createStore({ name: 'Alice', email: 'alice@example.com', }) const keys: string[] = [] for (const key in user) keys.push(key) expect(keys.sort()).toEqual(['email', 'name']) }) test('should support Object.getOwnPropertyDescriptor', () => { const user = createStore({ name: 'Alice' }) expect(Object.getOwnPropertyDescriptor(user, 'name')).toEqual({ enumerable: true, configurable: true, writable: true, value: user.name, }) }) test('should return undefined descriptor for Symbol properties', () => { const store = createStore({ a: 1 }) expect( Object.getOwnPropertyDescriptor(store, Symbol('test')), ).toBeUndefined() }) }) describe('Iteration', () => { test('should support spread operator', () => { const user = createStore({ name: 'John', age: 25 }) const entries = [...user] expect(entries).toHaveLength(2) // biome-ignore lint/style/noNonNullAssertion: test expect(entries[0]![0]).toBe('name') // biome-ignore lint/style/noNonNullAssertion: test expect(entries[0]![1].get()).toBe('John') // biome-ignore lint/style/noNonNullAssertion: test expect(entries[1]![0]).toBe('age') // biome-ignore lint/style/noNonNullAssertion: test expect(entries[1]![1].get()).toBe(25) }) test('should maintain property key ordering', () => { const config = createStore({ alpha: 1, beta: 2, gamma: 3 }) const entries = [...config] expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual( [ ['alpha', 1], ['beta', 2], ['gamma', 3], ], ) }) }) describe('Reactivity', () => { test('should react to property changes via get()', () => { const user = createStore({ name: 'John', email: 'john@example.com', }) let lastValue = { name: '', email: '' } createEffect(() => { lastValue = user.get() }) expect(lastValue).toEqual({ name: 'John', email: 'john@example.com', }) user.name.set('Jane') expect(lastValue).toEqual({ name: 'Jane', email: 'john@example.com', }) }) test('should support granular property-level subscriptions', () => { const user = createStore({ name: 'John', email: 'john@example.com', }) let nameRuns = 0 createEffect(() => { user.name.get() nameRuns++ }) expect(nameRuns).toBe(1) user.email.set('new@example.com') expect(nameRuns).toBe(1) // name effect not triggered user.name.set('Jane') expect(nameRuns).toBe(2) }) test('should propagate nested store changes to parent', () => { const user = createStore({ preferences: { theme: 'light' }, }) let runs = 0 createEffect(() => { user.get() runs++ }) expect(runs).toBe(1) user.preferences.theme.set('dark') expect(runs).toBe(2) }) test('should react to update()', () => { const user = createStore({ name: 'John' }) let lastValue: { name: string; email?: string } = { name: '' } let runs = 0 createEffect(() => { lastValue = user.get() runs++ }) expect(runs).toBe(1) user.update(u => ({ ...u, email: 'john@example.com' })) expect(lastValue).toEqual({ name: 'John', email: 'john@example.com', }) expect(runs).toBe(2) }) test('should work with createMemo', () => { const user = createStore({ firstName: 'John', lastName: 'Doe' }) const fullName = createMemo( () => `${user.firstName.get()} ${user.lastName.get()}`, ) expect(fullName.get()).toBe('John Doe') user.firstName.set('Jane') expect(fullName.get()).toBe('Jane Doe') }) test('should work with createMemo on nested stores', () => { const config = createStore({ ui: { theme: 'light' } }) const display = createMemo(() => `Theme: ${config.ui.theme.get()}`) expect(display.get()).toBe('Theme: light') config.ui.theme.set('dark') expect(display.get()).toBe('Theme: dark') }) }) describe('Serialization', () => { test('should round-trip through JSON', () => { const data = { user: { name: 'Alice', preferences: { theme: 'dark' } }, settings: { timeout: 5000 }, } const store = createStore(data) const parsed = JSON.parse(JSON.stringify(store.get())) expect(parsed).toEqual(data) }) }) describe('options.watched', () => { test('should activate on first effect and clean up on last', () => { let watchCount = 0 const store = createStore( { users: {} as Record<string, { name: string }> }, { watched: () => { watchCount++ return () => { watchCount-- } }, }, ) expect(watchCount).toBe(0) const cleanup1 = createEffect(() => { store.get() }) expect(watchCount).toBe(1) const cleanup2 = createEffect(() => { store.get() }) expect(watchCount).toBe(1) // still 1 cleanup2() expect(watchCount).toBe(1) // still active cleanup1() expect(watchCount).toBe(0) // cleaned up }) test('should not activate for nested property access only', async () => { let activated = false const store = createStore( { user: { name: 'John' } }, { watched: () => { activated = true return () => { activated = false } }, }, ) const cleanup = createEffect(() => { store.user.name.get() }) await new Promise(resolve => setTimeout(resolve, 10)) expect(activated).toBe(false) cleanup() }) test('should activate when keys() is called in an effect', () => { let watchCount = 0 const store = createStore<{ a: number; b?: number }>( { a: 1 }, { watched: () => { watchCount++ return () => { watchCount-- } }, }, ) expect(watchCount).toBe(0) const cleanup = createEffect(() => { Array.from(store.keys()).forEach(() => {}) }) expect(watchCount).toBe(1) cleanup() expect(watchCount).toBe(0) }) }) describe('Input Validation', () => { test('should throw for null initial value', () => { // @ts-expect-error testing null expect(() => createStore(null)).toThrow() }) test('should throw for undefined initial value', () => { // @ts-expect-error testing undefined expect(() => createStore(undefined)).toThrow() }) test('should throw for non-object initial value', () => { // @ts-expect-error testing primitive expect(() => createStore('hello')).toThrow() }) test('should throw for null value in add()', () => { const store = createStore<{ name: string; email?: string }>({ name: 'John', }) // @ts-expect-error testing null expect(() => store.add('email', null)).toThrow() }) }) })