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();
|
||||
|
||||
Reference in New Issue
Block a user