import { expect, tap } from '@push.rocks/tapbundle'; import * as smartstate from '../ts/index.js'; type TTestStateParts = 'initTest' | 'persistTest' | 'forceTest'; interface ITestState { value: number; nested: { data: string; }; } // ============================ // Init mode tests // ============================ tap.test('should handle soft init mode (default)', async () => { const state = new smartstate.Smartstate(); const statePart1 = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }); expect(statePart1.getState()).toEqual({ value: 1, nested: { data: 'initial' } }); const statePart2 = await state.getStatePart('initTest'); expect(statePart1 === statePart2).toBeTrue(); }); tap.test('should handle mandatory init mode', async () => { const state = new smartstate.Smartstate(); const statePart1 = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'mandatory'); expect(statePart1).toBeInstanceOf(smartstate.StatePart); let error: Error | null = null; try { await state.getStatePart('initTest', { value: 2, nested: { data: 'second' } }, 'mandatory'); } catch (e) { error = e as Error; } expect(error).not.toBeNull(); expect(error?.message).toMatch(/already exists.*mandatory/); }); tap.test('should handle force init mode', async () => { const state = new smartstate.Smartstate(); const statePart1 = await state.getStatePart('forceTest', { value: 1, nested: { data: 'initial' } }); expect(statePart1.getState()?.value).toEqual(1); const statePart2 = await state.getStatePart('forceTest', { value: 2, nested: { data: 'forced' } }, 'force'); expect(statePart2.getState()?.value).toEqual(2); expect(statePart1 === statePart2).toBeFalse(); }); tap.test('should handle missing initial state error', async () => { const state = new smartstate.Smartstate(); let error: Error | null = null; try { await state.getStatePart('initTest'); } catch (e) { error = e as Error; } expect(error).not.toBeNull(); expect(error?.message).toMatch(/does not exist.*no initial state/); }); tap.test('should handle state validation', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }); let error: Error | null = null; try { await statePart.setState(null as any); } catch (e) { error = e as Error; } expect(error).not.toBeNull(); expect(error?.message).toMatch(/Invalid state structure/); }); tap.test('should handle undefined state in select', async () => { const state = new smartstate.Smartstate(); const statePart = new smartstate.StatePart('initTest'); const values: (ITestState | undefined)[] = []; statePart.select().subscribe(val => values.push(val)); expect(values).toHaveLength(0); await statePart.setState({ value: 1, nested: { data: 'test' } }); expect(values).toHaveLength(1); expect(values[0]).toEqual({ value: 1, nested: { data: 'test' } }); }); tap.test('should not notify on duplicate state', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }); let notificationCount = 0; statePart.select().subscribe(() => notificationCount++); expect(notificationCount).toEqual(1); await statePart.setState({ value: 1, nested: { data: 'initial' } }); await statePart.setState({ value: 1, nested: { data: 'initial' } }); await statePart.setState({ value: 1, nested: { data: 'initial' } }); expect(notificationCount).toEqual(1); await statePart.setState({ value: 2, nested: { data: 'changed' } }); expect(notificationCount).toEqual(2); }); // ============================ // AbortSignal tests // ============================ tap.test('select should complete when AbortSignal fires', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }); const controller = new AbortController(); const values: any[] = []; let completed = false; statePart.select(undefined, { signal: controller.signal }).subscribe({ next: (v) => values.push(v), complete: () => { completed = true; }, }); expect(values.length).toBeGreaterThanOrEqual(1); controller.abort(); // Give microtask time await new Promise((r) => setTimeout(r, 10)); expect(completed).toBeTrue(); }); tap.test('select with pre-aborted signal should complete immediately', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }); const controller = new AbortController(); controller.abort(); let completed = false; statePart.select(undefined, { signal: controller.signal }).subscribe({ complete: () => { completed = true; }, }); await new Promise((r) => setTimeout(r, 10)); expect(completed).toBeTrue(); }); tap.test('waitUntilPresent should reject when AbortSignal fires', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 0, nested: { data: '' } }, 'force'); const controller = new AbortController(); const promise = statePart.waitUntilPresent( (s) => s.value > 100 ? s : undefined as any, { signal: controller.signal } ); // Abort before the condition can be met setTimeout(() => controller.abort(), 20); let error: Error | null = null; try { await promise; } catch (e) { error = e as Error; } expect(error).not.toBeNull(); expect(error?.message).toEqual('Aborted'); }); tap.test('waitUntilPresent should still work with numeric timeout (backward compat)', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 42, nested: { data: 'present' } }, 'force'); const result = await statePart.waitUntilPresent(undefined, 5000); expect(result.value).toEqual(42); }); // ============================ // Middleware tests // ============================ tap.test('middleware should transform state', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'force'); statePart.addMiddleware((newState, oldState) => { return { ...newState, nested: { data: newState.nested.data.toUpperCase() } }; }); await statePart.setState({ value: 2, nested: { data: 'hello' } }); expect(statePart.getState().nested.data).toEqual('HELLO'); }); tap.test('middleware should reject state changes on throw', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'force'); statePart.addMiddleware((newState) => { if (newState.value < 0) { throw new Error('Value must be non-negative'); } return newState; }); let error: Error | null = null; try { await statePart.setState({ value: -1, nested: { data: 'bad' } }); } catch (e) { error = e as Error; } expect(error).not.toBeNull(); expect(error?.message).toEqual('Value must be non-negative'); // State should be unchanged expect(statePart.getState().value).toEqual(1); }); tap.test('multiple middlewares should run in order', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'force'); const order: number[] = []; statePart.addMiddleware((newState) => { order.push(1); return { ...newState, value: newState.value + 10 }; }); statePart.addMiddleware((newState) => { order.push(2); return { ...newState, value: newState.value * 2 }; }); await statePart.setState({ value: 5, nested: { data: 'test' } }); expect(order).toEqual([1, 2]); // (5 + 10) * 2 = 30 expect(statePart.getState().value).toEqual(30); }); tap.test('middleware removal should work', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'force'); const remove = statePart.addMiddleware((newState) => { return { ...newState, value: newState.value * 100 }; }); await statePart.setState({ value: 2, nested: { data: 'test' } }); expect(statePart.getState().value).toEqual(200); remove(); await statePart.setState({ value: 3, nested: { data: 'test' } }); expect(statePart.getState().value).toEqual(3); }); // ============================ // Selector memoization tests // ============================ tap.test('select with same selector fn should return cached observable', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'force'); const selector = (s: ITestState) => s.value; const obs1 = statePart.select(selector); const obs2 = statePart.select(selector); expect(obs1).toEqual(obs2); }); tap.test('select with no args should return cached observable', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'force'); const obs1 = statePart.select(); const obs2 = statePart.select(); expect(obs1).toEqual(obs2); }); tap.test('select with different selectors should return different observables', async () => { const state = new smartstate.Smartstate(); const statePart = await state.getStatePart('initTest', { value: 1, nested: { data: 'initial' } }, 'force'); const obs1 = statePart.select((s) => s.value); const obs2 = statePart.select((s) => s.nested); expect(obs1).not.toEqual(obs2); }); // ============================ // Batch update tests // ============================ tap.test('batch should defer notifications until complete', async () => { type TBatchParts = 'partA' | 'partB'; const state = new smartstate.Smartstate(); const partA = await state.getStatePart('partA', { value: 1, nested: { data: 'a' } }); const partB = await state.getStatePart('partB', { value: 2, nested: { data: 'b' } }); const notificationsA: number[] = []; const notificationsB: number[] = []; partA.select((s) => s.value).subscribe((v) => notificationsA.push(v)); partB.select((s) => s.value).subscribe((v) => notificationsB.push(v)); // Reset after initial notifications notificationsA.length = 0; notificationsB.length = 0; await state.batch(async () => { await partA.setState({ value: 10, nested: { data: 'aa' } }); await partB.setState({ value: 20, nested: { data: 'bb' } }); // During batch, no notifications yet expect(notificationsA).toHaveLength(0); expect(notificationsB).toHaveLength(0); }); // After batch, both should have notified expect(notificationsA).toContain(10); expect(notificationsB).toContain(20); }); tap.test('nested batches should only flush at outermost level', async () => { type TBatchParts = 'nested'; const state = new smartstate.Smartstate(); const part = await state.getStatePart('nested', { value: 0, nested: { data: 'start' } }); const values: number[] = []; part.select((s) => s.value).subscribe((v) => values.push(v)); values.length = 0; await state.batch(async () => { await part.setState({ value: 1, nested: { data: 'a' } }); await state.batch(async () => { await part.setState({ value: 2, nested: { data: 'b' } }); // Still inside outer batch expect(values).toHaveLength(0); }); // Inner batch ended but outer batch still active expect(values).toHaveLength(0); }); // Now outer batch is done — should see final notification expect(values.length).toBeGreaterThanOrEqual(1); expect(values[values.length - 1]).toEqual(2); }); // ============================ // Computed state tests // ============================ tap.test('computed should derive from multiple state parts', async () => { type TComputedParts = 'first' | 'second'; const state = new smartstate.Smartstate(); const first = await state.getStatePart<{ count: number }>('first', { count: 5 }); const second = await state.getStatePart<{ count: number }>('second', { count: 10 }); const derived$ = state.computed( [first, second], (a, b) => a.count + b.count, ); const values: number[] = []; derived$.subscribe((v) => values.push(v)); expect(values).toContain(15); }); tap.test('computed should update when a source changes', async () => { type TComputedParts = 'x' | 'y'; const state = new smartstate.Smartstate(); const x = await state.getStatePart<{ n: number }>('x', { n: 1 }); const y = await state.getStatePart<{ n: number }>('y', { n: 2 }); const derived$ = state.computed( [x, y], (xState, yState) => xState.n * yState.n, ); const values: number[] = []; derived$.subscribe((v) => values.push(v)); // Initial: 1 * 2 = 2 expect(values[0]).toEqual(2); await x.setState({ n: 5 }); // After update: 5 * 2 = 10 expect(values[values.length - 1]).toEqual(10); }); tap.test('standalone computed function should work', async () => { type TParts = 'a' | 'b'; const state = new smartstate.Smartstate(); const a = await state.getStatePart<{ val: string }>('a', { val: 'hello' }); const b = await state.getStatePart<{ val: string }>('b', { val: 'world' }); const derived$ = smartstate.computed( [a, b], (aState, bState) => `${aState.val} ${bState.val}`, ); const values: string[] = []; derived$.subscribe((v) => values.push(v)); expect(values[0]).toEqual('hello world'); await a.setState({ val: 'hi' }); expect(values[values.length - 1]).toEqual('hi world'); }); // ============================ // Context Protocol tests // ============================ tap.test('attachContextProvider should respond to context-request events', async () => { // EventTarget and CustomEvent are available in Node 18+ if (typeof EventTarget === 'undefined') { console.log('Skipping context test — EventTarget not available'); return; } type TParts = 'ctx'; const state = new smartstate.Smartstate(); const statePart = await state.getStatePart<{ theme: string }>('ctx', { theme: 'dark' }); const myContext = Symbol('test-context'); // Use an EventTarget as a mock element const element = new EventTarget() as any as HTMLElement; const cleanup = smartstate.attachContextProvider(element, { context: myContext, statePart, }); let receivedValue: any = null; // Dispatch a context-request event const event = new CustomEvent('context-request', { detail: { context: myContext, callback: (value: any) => { receivedValue = value; }, subscribe: false, }, bubbles: true, composed: true, }); (element as any).dispatchEvent(event); expect(receivedValue).toEqual({ theme: 'dark' }); cleanup(); }); tap.test('attachContextProvider should support subscriptions', async () => { if (typeof EventTarget === 'undefined') { console.log('Skipping context subscription test — EventTarget not available'); return; } type TParts = 'ctxSub'; const state = new smartstate.Smartstate(); const statePart = await state.getStatePart<{ count: number }>('ctxSub', { count: 0 }); const myContext = Symbol('sub-context'); const element = new EventTarget() as any as HTMLElement; const cleanup = smartstate.attachContextProvider(element, { context: myContext, statePart, }); const receivedValues: any[] = []; let unsubFn: (() => void) | undefined; const event = new CustomEvent('context-request', { detail: { context: myContext, callback: (value: any, unsub?: () => void) => { receivedValues.push(value); if (unsub) unsubFn = unsub; }, subscribe: true, }, bubbles: true, composed: true, }); (element as any).dispatchEvent(event); expect(receivedValues).toHaveLength(1); expect(receivedValues[0]).toEqual({ count: 0 }); // Update state — should trigger subscription callback await statePart.setState({ count: 42 }); // Give a tick for the subscription to fire await new Promise((r) => setTimeout(r, 10)); expect(receivedValues.length).toBeGreaterThanOrEqual(2); expect(receivedValues[receivedValues.length - 1]).toEqual({ count: 42 }); // Unsubscribe expect(unsubFn).toBeDefined(); unsubFn!(); cleanup(); }); tap.test('attachContextProvider should ignore non-matching contexts', async () => { if (typeof EventTarget === 'undefined') { console.log('Skipping context mismatch test — EventTarget not available'); return; } type TParts = 'ctxMismatch'; const state = new smartstate.Smartstate(); const statePart = await state.getStatePart<{ v: number }>('ctxMismatch', { v: 1 }); const myContext = Symbol('my-context'); const otherContext = Symbol('other-context'); const element = new EventTarget() as any as HTMLElement; const cleanup = smartstate.attachContextProvider(element, { context: myContext, statePart, }); let called = false; const event = new CustomEvent('context-request', { detail: { context: otherContext, callback: () => { called = true; }, subscribe: false, }, bubbles: true, composed: true, }); (element as any).dispatchEvent(event); expect(called).toBeFalse(); cleanup(); }); // ============================ // Enterprise hardening tests // ============================ tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10)', async () => { type TParts = 'counter'; const state = new smartstate.Smartstate(); const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 }); const increment = counter.createAction(async (statePart) => { const current = statePart.getState(); return { count: current.count + 1 }; }); // Fire 10 concurrent increments (no await) const promises: Promise[] = []; for (let i = 0; i < 10; i++) { promises.push(counter.dispatchAction(increment, undefined)); } await Promise.all(promises); expect(counter.getState().count).toEqual(10); }); tap.test('concurrent setState should serialize (no lost updates)', async () => { type TParts = 'concurrent'; const state = new smartstate.Smartstate(); const part = await state.getStatePart<{ values: number[] }>('concurrent', { values: [] }); const promises: Promise[] = []; for (let i = 0; i < 5; i++) { promises.push( part.setState({ values: [...(part.getState()?.values || []), i] }) ); } await Promise.all(promises); // At minimum, the final state should have been set 5 times without error // The exact values depend on serialization timing, but state should be valid expect(part.getState()).toBeTruthy(); expect(part.getState().values).toBeInstanceOf(Array); }); tap.test('dispose should complete the Subject and notify subscribers', async () => { type TParts = 'disposable'; const state = new smartstate.Smartstate(); const part = await state.getStatePart<{ v: number }>('disposable', { v: 1 }); let completed = false; part.select().subscribe({ complete: () => { completed = true; }, }); part.dispose(); expect(completed).toBeTrue(); }); tap.test('falsy state {count: 0} should trigger notification', async () => { type TParts = 'falsy'; const state = new smartstate.Smartstate(); const part = await state.getStatePart<{ count: number }>('falsy', { count: 0 }); const values: number[] = []; part.select((s) => s.count).subscribe((v) => values.push(v)); // Initial value should include 0 expect(values).toContain(0); await part.setState({ count: 0 }); // Should not duplicate since hash is the same, but the initial notification should have fired expect(values.length).toBeGreaterThanOrEqual(1); }); tap.test('waitUntilPresent should resolve for falsy non-null values like false', async () => { type TParts = 'falsyWait'; const state = new smartstate.Smartstate(); const part = await state.getStatePart<{ flag: boolean }>('falsyWait', { flag: false }); const result = await part.waitUntilPresent((s) => s.flag as any, 2000); // false is not null/undefined, so it should resolve // Actually false IS falsy for `value !== undefined && value !== null` — false passes that check expect(result).toBeFalse(); }); tap.test('batch re-entrancy: setState during flush should not deadlock', async () => { type TParts = 'reentrant'; const state = new smartstate.Smartstate(); const part = await state.getStatePart<{ v: number }>('reentrant', { v: 0 }); let flushSetStateDone = false; // Subscribe and trigger a setState during notification flush part.select((s) => s.v).subscribe((v) => { if (v === 1 && !flushSetStateDone) { flushSetStateDone = true; // Fire-and-forget setState during notification — should not deadlock part.setState({ v: 2 }); } }); await state.batch(async () => { await part.setState({ v: 1 }); }); // Wait for the fire-and-forget setState to complete await new Promise((r) => setTimeout(r, 50)); expect(part.getState().v).toEqual(2); }); tap.test('force mode should dispose old StatePart (Subject completes)', async () => { type TParts = 'forceDispose'; const state = new smartstate.Smartstate(); const old = await state.getStatePart<{ v: number }>('forceDispose', { v: 1 }); let oldCompleted = false; old.select().subscribe({ complete: () => { oldCompleted = true; }, }); await state.getStatePart<{ v: number }>('forceDispose', { v: 2 }, 'force'); expect(oldCompleted).toBeTrue(); }); tap.test('getStatePart should accept 0 as initial value', async () => { type TParts = 'zeroInit'; const state = new smartstate.Smartstate(); // 0 is falsy but should be accepted as a valid initial value const part = await state.getStatePart('zeroInit', 0); expect(part.getState()).toEqual(0); }); export default tap.start();