import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as smartstate from '../ts/index.js'; import { Subject, of, Observable, throwError, concat } from 'rxjs'; // ── Lifecycle ────────────────────────────────────────────────────────── tap.test('process should start in idle status', async () => { const state = new smartstate.Smartstate<'test'>(); const part = await state.getStatePart<{ v: number }>('test', { v: 0 }); const process = part.createProcess({ producer: () => of(1), reducer: (s, v) => ({ v: s.v + v }), }); expect(process.status).toEqual('idle'); process.dispose(); }); tap.test('start/pause/resume/dispose lifecycle', async () => { const state = new smartstate.Smartstate<'lifecycle'>(); const part = await state.getStatePart<{ v: number }>('lifecycle', { v: 0 }); const subject = new Subject(); const process = part.createProcess({ producer: () => subject.asObservable(), reducer: (s, v) => ({ v: s.v + v }), }); expect(process.status).toEqual('idle'); process.start(); expect(process.status).toEqual('running'); process.pause(); expect(process.status).toEqual('paused'); process.resume(); expect(process.status).toEqual('running'); process.dispose(); expect(process.status).toEqual('disposed'); }); // ── Producer → state integration ─────────────────────────────────────── tap.test('producer values should update state through reducer', async () => { const state = new smartstate.Smartstate<'producer'>(); const part = await state.getStatePart<{ values: number[] }>('producer', { values: [] }); const subject = new Subject(); const process = part.createProcess({ producer: () => subject.asObservable(), reducer: (s, v) => ({ values: [...s.values, v] }), }); process.start(); subject.next(1); subject.next(2); subject.next(3); await new Promise((r) => setTimeout(r, 100)); expect(part.getState()!.values).toEqual([1, 2, 3]); process.dispose(); }); // ── Pause stops producer, resume restarts ────────────────────────────── tap.test('pause should stop receiving values, resume should restart', async () => { const state = new smartstate.Smartstate<'pauseResume'>(); const part = await state.getStatePart<{ count: number }>('pauseResume', { count: 0 }); const subject = new Subject(); const process = part.createProcess({ producer: () => subject.asObservable(), reducer: (s, v) => ({ count: s.count + v }), }); process.start(); subject.next(1); await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.count).toEqual(1); process.pause(); subject.next(1); // should be ignored — producer unsubscribed await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.count).toEqual(1); // unchanged process.resume(); subject.next(1); await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.count).toEqual(2); process.dispose(); }); // ── Auto-pause with custom Observable ────────────────────────────────── tap.test('auto-pause with custom Observable signal', async () => { const state = new smartstate.Smartstate<'autoPause'>(); const part = await state.getStatePart<{ count: number }>('autoPause', { count: 0 }); const producer = new Subject(); const pauseSignal = new Subject(); const process = part.createProcess({ producer: () => producer.asObservable(), reducer: (s, v) => ({ count: s.count + v }), autoPause: pauseSignal.asObservable(), }); process.start(); producer.next(1); await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.count).toEqual(1); // Signal pause pauseSignal.next(false); await new Promise((r) => setTimeout(r, 50)); expect(process.status).toEqual('paused'); producer.next(1); // ignored await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.count).toEqual(1); // Signal resume pauseSignal.next(true); await new Promise((r) => setTimeout(r, 50)); expect(process.status).toEqual('running'); producer.next(1); await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.count).toEqual(2); process.dispose(); }); // ── Auto-pause 'visibility' in Node.js (no document) ────────────────── tap.test('autoPause visibility should be always-active in Node.js', async () => { const state = new smartstate.Smartstate<'vis'>(); const part = await state.getStatePart<{ v: number }>('vis', { v: 0 }); const subject = new Subject(); const process = part.createProcess({ producer: () => subject.asObservable(), reducer: (s, v) => ({ v: v }), autoPause: 'visibility', }); process.start(); expect(process.status).toEqual('running'); subject.next(42); await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.v).toEqual(42); process.dispose(); }); // ── Scheduled action ─────────────────────────────────────────────────── tap.test('createScheduledAction should dispatch action on interval', async () => { const state = new smartstate.Smartstate<'scheduled'>(); const part = await state.getStatePart<{ ticks: number }>('scheduled', { ticks: 0 }); const tickAction = part.createAction(async (sp) => { return { ticks: sp.getState()!.ticks + 1 }; }); const scheduled = part.createScheduledAction({ action: tickAction, payload: undefined, intervalMs: 50, }); await new Promise((r) => setTimeout(r, 280)); scheduled.dispose(); // Should have ticked at least 3 times in ~280ms with 50ms interval expect(part.getState()!.ticks).toBeGreaterThanOrEqual(3); }); // ── StatePart.dispose cascades ───────────────────────────────────────── tap.test('StatePart.dispose should dispose all processes', async () => { const state = new smartstate.Smartstate<'cascade'>(); const part = await state.getStatePart<{ v: number }>('cascade', { v: 0 }); const p1 = part.createProcess({ producer: () => new Subject().asObservable(), reducer: (s, v) => ({ v }), }); const p2 = part.createProcess({ producer: () => new Subject().asObservable(), reducer: (s, v) => ({ v }), }); p1.start(); p2.start(); part.dispose(); expect(p1.status).toEqual('disposed'); expect(p2.status).toEqual('disposed'); }); // ── status$ observable ───────────────────────────────────────────────── tap.test('status$ should emit lifecycle transitions', async () => { const state = new smartstate.Smartstate<'status$'>(); const part = await state.getStatePart<{ v: number }>('status$', { v: 0 }); const subject = new Subject(); const process = part.createProcess({ producer: () => subject.asObservable(), reducer: (s, v) => ({ v }), }); const statuses: string[] = []; process.status$.subscribe((s) => statuses.push(s)); process.start(); process.pause(); process.resume(); process.dispose(); expect(statuses).toEqual(['idle', 'running', 'paused', 'running', 'disposed']); }); // ── Producer error → graceful pause ──────────────────────────────────── tap.test('producer error should pause process gracefully', async () => { const state = new smartstate.Smartstate<'error'>(); const part = await state.getStatePart<{ v: number }>('error', { v: 0 }); let callCount = 0; const process = part.createProcess({ producer: () => { callCount++; if (callCount === 1) { // First subscription: emit 1, then error return concat(of(1), throwError(() => new Error('boom'))); } // After resume: emit 2 successfully return of(2); }, reducer: (s, v) => ({ v }), }); process.start(); await new Promise((r) => setTimeout(r, 50)); expect(process.status).toEqual('paused'); expect(part.getState()!.v).toEqual(1); // got the value before error // Resume creates a fresh subscription process.resume(); await new Promise((r) => setTimeout(r, 50)); expect(part.getState()!.v).toEqual(2); process.dispose(); }); // ── Disposed guards ──────────────────────────────────────────────────── tap.test('start/pause/resume on disposed process should throw', async () => { const state = new smartstate.Smartstate<'guards'>(); const part = await state.getStatePart<{ v: number }>('guards', { v: 0 }); const process = part.createProcess({ producer: () => of(1), reducer: (s, v) => ({ v }), }); process.dispose(); let errors = 0; try { process.start(); } catch { errors++; } try { process.pause(); } catch { errors++; } try { process.resume(); } catch { errors++; } expect(errors).toEqual(3); }); // ── autoStart option ─────────────────────────────────────────────────── tap.test('autoStart should start process immediately', async () => { const state = new smartstate.Smartstate<'autoStart'>(); const part = await state.getStatePart<{ v: number }>('autoStart', { v: 0 }); const process = part.createProcess({ producer: () => of(42), reducer: (s, v) => ({ v }), autoStart: true, }); await new Promise((r) => setTimeout(r, 50)); expect(process.status).toEqual('running'); expect(part.getState()!.v).toEqual(42); process.dispose(); }); export default tap.start();