UNPKG

@zeix/cause-effect

Version:

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

391 lines (355 loc) 11.8 kB
import { describe, expect, test } from 'bun:test' import { batch, createEffect, createList, createMemo, createScope, createSensor, createSlot, createState, createTask, match, } from '../index.ts' const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) // ─── Guide: Keyed Collections ──────────────────────────────────────────────── describe('Guide: Keyed Collections', () => { type Todo = { id: string; title: string; done: boolean } test('list with content-based keyConfig assigns item.id as key', () => { const todos = createList<Todo>( [ { id: 'a', title: 'Write docs', done: false }, { id: 'b', title: 'Ship release', done: true }, ], { keyConfig: item => item.id }, ) expect(todos.keyAt(0)).toBe('a') expect(todos.keyAt(1)).toBe('b') expect(todos.byKey('a')?.get()).toEqual({ id: 'a', title: 'Write docs', done: false, }) }) test('byKey returns the same signal reference before and after replace', () => { const todos = createList<Todo>( [{ id: 'a', title: 'Write docs', done: false }], { keyConfig: item => item.id }, ) const before = todos.byKey('a') todos.replace('a', { id: 'a', title: 'Write final docs', done: false }) expect(todos.byKey('a')).toBe(before) expect(todos.byKey('a')?.get().title).toBe('Write final docs') }) test('replace notifies an effect reading the item signal directly', () => { const todos = createList<Todo>( [{ id: 'a', title: 'Write docs', done: false }], { keyConfig: item => item.id }, ) const seen: string[] = [] const itemSignal = todos.byKey('a') const dispose = createEffect(() => { seen.push(itemSignal.get().title) }) todos.replace('a', { id: 'a', title: 'Write final docs', done: false }) expect(seen).toEqual(['Write docs', 'Write final docs']) dispose() }) test('deriveCollection maps item values to a read-only collection', () => { const todos = createList<Todo>( [ { id: 'a', title: 'Write docs', done: false }, { id: 'b', title: 'Ship release', done: true }, ], { keyConfig: item => item.id }, ) const visibleTitles = todos.deriveCollection(item => item.done ? 'archived' : item.title, ) expect(visibleTitles.get()).toEqual(['Write docs', 'archived']) }) test('deriveCollection updates when an item is replaced', () => { const todos = createList<Todo>( [ { id: 'a', title: 'Write docs', done: false }, { id: 'b', title: 'Ship release', done: true }, ], { keyConfig: item => item.id }, ) const visibleTitles = todos.deriveCollection(item => item.done ? 'archived' : item.title, ) todos.replace('b', { id: 'b', title: 'Ship release', done: false }) expect(visibleTitles.get()).toEqual(['Write docs', 'Ship release']) }) test('sort reorders items and derived collection follows', () => { const todos = createList<Todo>( [ { id: 'a', title: 'Write docs', done: false }, { id: 'b', title: 'Audit docs build', done: false }, { id: 'c', title: 'Ship release', done: false }, ], { keyConfig: item => item.id }, ) const titles = todos.deriveCollection(item => item.title) todos.sort((a, b) => a.title.localeCompare(b.title)) expect(todos.get().map(t => t.title)).toEqual([ 'Audit docs build', 'Ship release', 'Write docs', ]) expect(titles.get()).toEqual([ 'Audit docs build', 'Ship release', 'Write docs', ]) }) test('add inserts a new item and increments length', () => { const todos = createList<Todo>( [{ id: 'a', title: 'Write docs', done: false }], { keyConfig: item => item.id }, ) todos.add({ id: 'c', title: 'Audit docs build', done: false }) expect(todos.length).toBe(2) expect(todos.byKey('c')?.get().title).toBe('Audit docs build') }) test('openCount memo tracks undone items across mutations', () => { const todos = createList<Todo>( [ { id: 'a', title: 'Write docs', done: false }, { id: 'b', title: 'Ship release', done: true }, ], { keyConfig: item => item.id }, ) const openCount = createMemo( () => todos.get().filter(item => !item.done).length, ) expect(openCount.get()).toBe(1) todos.replace('b', { id: 'b', title: 'Ship release', done: false }) expect(openCount.get()).toBe(2) }) test('full guide: replace + add + sort produce correct order and derived values', () => { const todos = createList<Todo>( [ { id: 'a', title: 'Write docs', done: false }, { id: 'b', title: 'Ship release', done: true }, ], { keyConfig: item => item.id }, ) const openCount = createMemo( () => todos.get().filter(item => !item.done).length, ) const visibleTitles = todos.deriveCollection(item => item.done ? 'archived' : item.title, ) todos.replace('a', { id: 'a', title: 'Write final docs', done: false }) todos.add({ id: 'c', title: 'Audit docs build', done: false }) todos.sort((a, b) => a.title.localeCompare(b.title)) // After sort: 'Audit docs build' < 'Ship release' < 'Write final docs' expect(todos.get().map(t => t.title)).toEqual([ 'Audit docs build', 'Ship release', 'Write final docs', ]) expect(openCount.get()).toBe(2) // 'Audit docs build' and 'Write final docs' are open expect(visibleTitles.get()).toEqual([ 'Audit docs build', 'archived', 'Write final docs', ]) }) }) // ─── Guide: Async Data Pipelines ───────────────────────────────────────────── describe('Guide: Async Data Pipelines', () => { type SearchResponse = { items: { id: string; title: string }[] total: number } const SEED: SearchResponse = { items: [], total: 0 } test('task with seed value enters stale state on first match', async () => { const query = createState('books') const results = createTask<SearchResponse>( async (_prev, abort) => { await wait(50) if (abort.aborted) return SEED return { items: [{ id: '1', title: query.get() }], total: 1 } }, { value: SEED }, ) const states: string[] = [] const dispose = createEffect(() => { match(results, { stale: () => states.push('stale'), ok: data => states.push(`ok:${data.total}`), }) }) expect(states).toEqual(['stale']) await wait(60) expect(states).toEqual(['stale', 'ok:1']) dispose() }) test('derived memo reads seed value while task is pending', async () => { const results = createTask<SearchResponse>( async () => { await wait(50) return { items: [{ id: '1', title: 'test' }], total: 42 } }, { value: SEED }, ) const totalPages = createMemo(() => Math.max(1, Math.ceil(results.get().total / 20)), ) results.get() // trigger computation expect(totalPages.get()).toBe(1) // seed total=0 → max(1, ceil(0/20))=1 await wait(60) expect(totalPages.get()).toBe(3) // resolved total=42 → ceil(42/20)=3 }) test('batch prevents duplicate task run when query and page change together', async () => { const query = createState('books') const page = createState(1) const requestKey = createMemo(() => ({ query: query.get().trim(), page: page.get(), })) let runCount = 0 const results = createTask<SearchResponse>( async (_prev, abort) => { runCount++ const { query: q, page: p } = requestKey.get() await wait(30) if (abort.aborted) return SEED return { items: [{ id: '1', title: `${q} p${p}` }], total: 1 } }, { value: SEED }, ) const dispose = createEffect(() => { match(results, { stale: () => {}, ok: () => {}, }) }) await wait(50) // first run completes const firstRunCount = runCount batch(() => { query.set('signals') page.set(2) // changing both ensures a meaningful batch test }) await wait(60) // second run completes expect(runCount).toBe(firstRunCount + 1) // exactly one additional run expect(results.get().items[0]?.title).toBe('signals p2') dispose() }) test('AbortSignal is triggered when dependency changes before task completes', async () => { const query = createState('books') const aborted: string[] = [] const results = createTask<SearchResponse>( async (_prev, signal) => { const q = query.get() await wait(80) if (signal.aborted) { aborted.push(q) return SEED } return { items: [{ id: '1', title: q }], total: 1 } }, { value: SEED }, ) const dispose = createEffect(() => { match(results, { stale: () => {}, ok: () => {}, }) }) await wait(20) // first run ('books') is in flight query.set('signals') // aborts 'books' run, starts 'signals' run await wait(100) // 'signals' run completes 80ms after re-trigger expect(aborted).toContain('books') expect(results.get().items[0]?.title).toBe('signals') dispose() }) }) // ─── Guide: Custom Elements and External Lifecycles ────────────────────────── describe('Guide: Custom Elements and External Lifecycles', () => { test('slot used as property descriptor reads from and writes to backing signal', () => { const source = createState('draft') const slot = createSlot(source) const target: Record<string, unknown> = {} Object.defineProperty(target, 'value', slot) expect((target as { value: string }).value).toBe('draft') ;(target as { value: string }).value = 'published' expect(source.get()).toBe('published') }) test('slot.replace swaps backing signal and downstream effects resubscribe', () => { const internalValue = createState('draft') const slot = createSlot(internalValue) const log: string[] = [] const dispose = createEffect(() => { log.push(slot.get()) }) const controlled = createMemo(() => `status:${internalValue.get()}`) slot.replace(controlled) expect(log).toEqual(['draft', 'status:draft']) internalValue.set('published') expect(log).toEqual(['draft', 'status:draft', 'status:published']) dispose() }) test('sensor watched callback starts lazily on first subscription and stops on dispose', () => { let started = false let stopped = false let push!: (v: boolean) => void const sensor = createSensor<boolean>(set => { started = true push = set push(true) return () => { stopped = true } }) expect(started).toBe(false) // lazy — not started until first subscriber const log: boolean[] = [] const dispose = createEffect(() => { log.push(sensor.get()) }) expect(started).toBe(true) expect(log).toEqual([true]) push(false) expect(log).toEqual([true, false]) expect(stopped).toBe(false) dispose() expect(stopped).toBe(true) // cleanup runs when last subscriber unsubscribes }) test('simulated custom element: createScope root:true is sole lifecycle authority', () => { const label = createState('idle') const log: string[] = [] class MockElement { #dispose?: (() => void) | undefined textContent = '' connectedCallback() { this.#dispose = createScope( () => { createEffect(() => { this.textContent = label.get() log.push(this.textContent) }) }, { root: true }, ) } disconnectedCallback() { this.#dispose?.() this.#dispose = undefined } } const el = new MockElement() el.connectedCallback() expect(log).toEqual(['idle']) label.set('active') expect(log).toEqual(['idle', 'active']) el.disconnectedCallback() label.set('gone') expect(log).toEqual(['idle', 'active']) // scope disposed — no more updates el.connectedCallback() // reconnect: fresh scope picks up current label value expect(log).toEqual(['idle', 'active', 'gone']) el.disconnectedCallback() }) })