feat(stateprocess): add managed state processes with lifecycle controls, scheduled actions, and disposal safety

This commit is contained in:
2026-03-27 11:32:49 +00:00
parent b417e3d049
commit 034ae56536
16 changed files with 3338 additions and 2284 deletions

View File

@@ -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();