Files
smartstate/test/test.stateprocess.ts

279 lines
10 KiB
TypeScript

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<number>({
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<number>();
const process = part.createProcess<number>({
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<number>();
const process = part.createProcess<number>({
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<number>();
const process = part.createProcess<number>({
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<boolean> signal', async () => {
const state = new smartstate.Smartstate<'autoPause'>();
const part = await state.getStatePart<{ count: number }>('autoPause', { count: 0 });
const producer = new Subject<number>();
const pauseSignal = new Subject<boolean>();
const process = part.createProcess<number>({
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<number>();
const process = part.createProcess<number>({
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<void>(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<number>({
producer: () => new Subject<number>().asObservable(),
reducer: (s, v) => ({ v }),
});
const p2 = part.createProcess<number>({
producer: () => new Subject<number>().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<number>();
const process = part.createProcess<number>({
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<number>({
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<number>({
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<number>({
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();