fix(types,testing): tighten action context typing and update tests for stricter TypeScript checks

This commit is contained in:
2026-04-30 09:58:42 +00:00
parent a66518bde8
commit a62fa83afc
9 changed files with 97 additions and 18706 deletions
+29 -21
View File
@@ -1,4 +1,4 @@
import { expect, tap } from '@push.rocks/tapbundle';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartstate from '../ts/index.js';
type TTestStateParts = 'initTest' | 'persistTest' | 'forceTest';
@@ -10,6 +10,14 @@ interface ITestState {
};
}
const getRequiredState = <TPayload>(statePart: smartstate.StatePart<any, TPayload>): TPayload => {
const state = statePart.getState();
if (state === undefined) {
throw new Error('Expected state part to have initialized state');
}
return state;
};
// ============================
// Init mode tests
// ============================
@@ -245,7 +253,7 @@ tap.test('middleware should transform state', async () => {
});
await statePart.setState({ value: 2, nested: { data: 'hello' } });
expect(statePart.getState().nested.data).toEqual('HELLO');
expect(getRequiredState(statePart).nested.data).toEqual('HELLO');
});
tap.test('middleware should reject state changes on throw', async () => {
@@ -272,7 +280,7 @@ tap.test('middleware should reject state changes on throw', async () => {
expect(error).not.toBeNull();
expect(error?.message).toEqual('Value must be non-negative');
// State should be unchanged
expect(statePart.getState().value).toEqual(1);
expect(getRequiredState(statePart).value).toEqual(1);
});
tap.test('multiple middlewares should run in order', async () => {
@@ -297,7 +305,7 @@ tap.test('multiple middlewares should run in order', async () => {
await statePart.setState({ value: 5, nested: { data: 'test' } });
expect(order).toEqual([1, 2]);
// (5 + 10) * 2 = 30
expect(statePart.getState().value).toEqual(30);
expect(getRequiredState(statePart).value).toEqual(30);
});
tap.test('middleware removal should work', async () => {
@@ -312,12 +320,12 @@ tap.test('middleware removal should work', async () => {
});
await statePart.setState({ value: 2, nested: { data: 'test' } });
expect(statePart.getState().value).toEqual(200);
expect(getRequiredState(statePart).value).toEqual(200);
remove();
await statePart.setState({ value: 3, nested: { data: 'test' } });
expect(statePart.getState().value).toEqual(3);
expect(getRequiredState(statePart).value).toEqual(3);
});
// ============================
@@ -639,7 +647,7 @@ tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10
const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 });
const increment = counter.createAction<void>(async (statePart) => {
const current = statePart.getState();
const current = getRequiredState(statePart);
return { count: current.count + 1 };
});
@@ -650,7 +658,7 @@ tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10
}
await Promise.all(promises);
expect(counter.getState().count).toEqual(10);
expect(getRequiredState(counter).count).toEqual(10);
});
tap.test('concurrent setState should serialize (no lost updates)', async () => {
@@ -669,7 +677,7 @@ tap.test('concurrent setState should serialize (no lost updates)', async () => {
// At minimum, the final state should have been set 5 times without error
// The exact values depend on serialization timing, but state should be valid
expect(part.getState()).toBeTruthy();
expect(part.getState().values).toBeInstanceOf(Array);
expect(getRequiredState(part).values).toBeInstanceOf(Array);
});
tap.test('dispose should complete the Subject and notify subscribers', async () => {
@@ -737,7 +745,7 @@ tap.test('batch re-entrancy: setState during flush should not deadlock', async (
// Wait for the fire-and-forget setState to complete
await new Promise<void>((r) => setTimeout(r, 50));
expect(part.getState().v).toEqual(2);
expect(getRequiredState(part).v).toEqual(2);
});
tap.test('force mode should dispose old StatePart (Subject completes)', async () => {
@@ -774,13 +782,13 @@ tap.test('context.dispatch should not deadlock on same StatePart', async () => {
const part = await state.getStatePart<{ count: number }>('reentrantAction', { count: 0 });
const innerIncrement = part.createAction<void>(async (sp) => {
return { count: sp.getState().count + 1 };
return { count: getRequiredState(sp).count + 1 };
});
const outerAction = part.createAction<void>(async (sp, _payload, context) => {
// This would deadlock without the context.dispatch() mechanism
await context.dispatch(innerIncrement, undefined);
return sp.getState();
return getRequiredState(sp);
});
const result = await part.dispatchAction(outerAction, undefined);
@@ -793,23 +801,23 @@ tap.test('deeply nested context.dispatch should work (3 levels)', async () => {
const part = await state.getStatePart<{ steps: string[] }>('deepNested', { steps: [] });
const appendStep = part.createAction<string>(async (sp, step) => {
return { steps: [...sp.getState().steps, step] };
return { steps: [...getRequiredState(sp).steps, step] };
});
const level2 = part.createAction<void>(async (sp, _payload, context) => {
await context.dispatch(appendStep, 'level-2');
return sp.getState();
return getRequiredState(sp);
});
const level1 = part.createAction<void>(async (sp, _payload, context) => {
await context.dispatch(appendStep, 'level-1');
await context.dispatch(level2, undefined);
await context.dispatch(appendStep, 'level-1-after');
return sp.getState();
return getRequiredState(sp);
});
await part.dispatchAction(level1, undefined);
expect(part.getState().steps).toEqual(['level-1', 'level-2', 'level-1-after']);
expect(getRequiredState(part).steps).toEqual(['level-1', 'level-2', 'level-1-after']);
});
tap.test('circular context.dispatch should throw max depth error', async () => {
@@ -820,12 +828,12 @@ tap.test('circular context.dispatch should throw max depth error', async () => {
// Create a self-referencing action that will loop forever
const circularAction: smartstate.StateAction<{ count: number }, void> = part.createAction<void>(
async (sp, _payload, context) => {
const current = sp.getState();
const current = getRequiredState(sp);
if (current.count < 100) {
// This should eventually hit the depth limit
await context.dispatch(circularAction, undefined);
}
return sp.getState();
return getRequiredState(sp);
}
);
@@ -851,7 +859,7 @@ tap.test('actions without context arg should still work (backward compat)', asyn
});
await part.dispatchAction(simpleAction, 42);
expect(part.getState().value).toEqual(42);
expect(getRequiredState(part).value).toEqual(42);
});
tap.test('concurrent dispatches still serialize correctly with context feature', async () => {
@@ -860,7 +868,7 @@ tap.test('concurrent dispatches still serialize correctly with context feature',
const part = await state.getStatePart<{ count: number }>('concurrentWithContext', { count: 0 });
const increment = part.createAction<void>(async (sp) => {
const current = sp.getState();
const current = getRequiredState(sp);
return { count: current.count + 1 };
});
@@ -871,7 +879,7 @@ tap.test('concurrent dispatches still serialize correctly with context feature',
}
await Promise.all(promises);
expect(part.getState().count).toEqual(10);
expect(getRequiredState(part).count).toEqual(10);
});
// ── distinctUntilChanged on selectors ──────────────────────────────────