feat(stateprocess): add managed state processes with lifecycle controls, scheduled actions, and disposal safety
This commit is contained in:
@@ -874,4 +874,122 @@ tap.test('concurrent dispatches still serialize correctly with context feature',
|
||||
expect(part.getState().count).toEqual(10);
|
||||
});
|
||||
|
||||
// ── distinctUntilChanged on selectors ──────────────────────────────────
|
||||
tap.test('select should not emit when selected sub-state has not changed', async () => {
|
||||
type TParts = 'distinctSelector';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ name: string; count: number }>('distinctSelector', { name: 'alice', count: 0 });
|
||||
|
||||
const nameSelector = (s: { name: string; count: number }) => s.name;
|
||||
const nameValues: string[] = [];
|
||||
part.select(nameSelector).subscribe((v) => nameValues.push(v));
|
||||
|
||||
// Wait for initial emission
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(1);
|
||||
expect(nameValues[0]).toEqual('alice');
|
||||
|
||||
// Change only count — name selector should NOT re-emit
|
||||
await part.setState({ name: 'alice', count: 1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(1);
|
||||
|
||||
// Change name — name selector SHOULD emit
|
||||
await part.setState({ name: 'bob', count: 1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(nameValues).toHaveLength(2);
|
||||
expect(nameValues[1]).toEqual('bob');
|
||||
});
|
||||
|
||||
// ── Selector error skipping ────────────────────────────────────────────
|
||||
tap.test('selector errors should be skipped, not emit undefined', async () => {
|
||||
type TParts = 'selectorError';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ value: number }>('selectorError', { value: 1 });
|
||||
|
||||
let callCount = 0;
|
||||
const faultySelector = (s: { value: number }) => {
|
||||
if (s.value === 2) throw new Error('selector boom');
|
||||
return s.value;
|
||||
};
|
||||
|
||||
const values: number[] = [];
|
||||
part.select(faultySelector).subscribe((v) => {
|
||||
callCount++;
|
||||
values.push(v);
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1]);
|
||||
|
||||
// This setState triggers a selector error — should be skipped, no undefined emitted
|
||||
await part.setState({ value: 2 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1]); // no new emission
|
||||
expect(callCount).toEqual(1);
|
||||
|
||||
// Normal value again
|
||||
await part.setState({ value: 3 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(values).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
// ── Smartstate.dispose() ───────────────────────────────────────────────
|
||||
tap.test('Smartstate.dispose should dispose all state parts', async () => {
|
||||
type TParts = 'partA' | 'partB';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const partA = await state.getStatePart<{ x: number }>('partA', { x: 1 });
|
||||
const partB = await state.getStatePart<{ y: number }>('partB', { y: 2 });
|
||||
|
||||
let aCompleted = false;
|
||||
let bCompleted = false;
|
||||
partA.select().subscribe({ complete: () => { aCompleted = true; } });
|
||||
partB.select().subscribe({ complete: () => { bCompleted = true; } });
|
||||
|
||||
state.dispose();
|
||||
|
||||
expect(aCompleted).toBeTrue();
|
||||
expect(bCompleted).toBeTrue();
|
||||
});
|
||||
|
||||
// ── Post-dispose setState throws ───────────────────────────────────────
|
||||
tap.test('setState on disposed StatePart should throw', async () => {
|
||||
type TParts = 'disposedPart';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposedPart', { v: 0 });
|
||||
|
||||
part.dispose();
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await part.setState({ v: 1 });
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('disposed');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
// ── Post-dispose dispatchAction throws ─────────────────────────────────
|
||||
tap.test('dispatchAction on disposed StatePart should throw', async () => {
|
||||
type TParts = 'disposedDispatch';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposedDispatch', { v: 0 });
|
||||
|
||||
const action = part.createAction<number>(async (sp, payload) => {
|
||||
return { v: payload };
|
||||
});
|
||||
|
||||
part.dispose();
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await part.dispatchAction(action, 5);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('disposed');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
278
test/test.stateprocess.ts
Normal file
278
test/test.stateprocess.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user