279 lines
10 KiB
TypeScript
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();
|