2025-07-29 19:26:03 +00:00
|
|
|
import { expect, tap } from '@push.rocks/tapbundle';
|
|
|
|
|
import * as smartstate from '../ts/index.js';
|
|
|
|
|
|
|
|
|
|
type TTestStateParts = 'initTest' | 'persistTest' | 'forceTest';
|
|
|
|
|
|
|
|
|
|
interface ITestState {
|
|
|
|
|
value: number;
|
|
|
|
|
nested: {
|
|
|
|
|
data: string;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 11:40:07 +00:00
|
|
|
// ============================
|
|
|
|
|
// Init mode tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
tap.test('should handle soft init mode (default)', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
});
|
|
|
|
|
expect(statePart1.getState()).toEqual({
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
});
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
const statePart2 = await state.getStatePart<ITestState>('initTest');
|
2026-02-27 11:40:07 +00:00
|
|
|
expect(statePart1 === statePart2).toBeTrue();
|
2025-07-29 19:26:03 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('should handle mandatory init mode', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'mandatory');
|
|
|
|
|
expect(statePart1).toBeInstanceOf(smartstate.StatePart);
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
let error: Error | null = null;
|
|
|
|
|
try {
|
|
|
|
|
await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 2,
|
|
|
|
|
nested: { data: 'second' }
|
|
|
|
|
}, 'mandatory');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error = e as Error;
|
|
|
|
|
}
|
|
|
|
|
expect(error).not.toBeNull();
|
|
|
|
|
expect(error?.message).toMatch(/already exists.*mandatory/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('should handle force init mode', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
const statePart1 = await state.getStatePart<ITestState>('forceTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
});
|
|
|
|
|
expect(statePart1.getState()?.value).toEqual(1);
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
const statePart2 = await state.getStatePart<ITestState>('forceTest', {
|
|
|
|
|
value: 2,
|
|
|
|
|
nested: { data: 'forced' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
expect(statePart2.getState()?.value).toEqual(2);
|
2026-02-27 11:40:07 +00:00
|
|
|
expect(statePart1 === statePart2).toBeFalse();
|
2025-07-29 19:26:03 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('should handle missing initial state error', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
let error: Error | null = null;
|
|
|
|
|
try {
|
|
|
|
|
await state.getStatePart<ITestState>('initTest');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error = e as Error;
|
|
|
|
|
}
|
|
|
|
|
expect(error).not.toBeNull();
|
|
|
|
|
expect(error?.message).toMatch(/does not exist.*no initial state/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('should handle state validation', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
});
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
let error: Error | null = null;
|
|
|
|
|
try {
|
|
|
|
|
await statePart.setState(null as any);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error = e as Error;
|
|
|
|
|
}
|
|
|
|
|
expect(error).not.toBeNull();
|
|
|
|
|
expect(error?.message).toMatch(/Invalid state structure/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('should handle undefined state in select', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = new smartstate.StatePart<TTestStateParts, ITestState>('initTest');
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
const values: (ITestState | undefined)[] = [];
|
|
|
|
|
statePart.select().subscribe(val => values.push(val));
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
expect(values).toHaveLength(0);
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
await statePart.setState({
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'test' }
|
|
|
|
|
});
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
expect(values).toHaveLength(1);
|
|
|
|
|
expect(values[0]).toEqual({
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'test' }
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('should not notify on duplicate state', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
});
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
let notificationCount = 0;
|
|
|
|
|
statePart.select().subscribe(() => notificationCount++);
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
expect(notificationCount).toEqual(1);
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
|
|
|
|
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
|
|
|
|
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
expect(notificationCount).toEqual(1);
|
2026-02-27 11:40:07 +00:00
|
|
|
|
2025-07-29 19:26:03 +00:00
|
|
|
await statePart.setState({ value: 2, nested: { data: 'changed' } });
|
|
|
|
|
expect(notificationCount).toEqual(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-27 11:40:07 +00:00
|
|
|
// ============================
|
|
|
|
|
// AbortSignal tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
tap.test('select should complete when AbortSignal fires', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const values: any[] = [];
|
|
|
|
|
let completed = false;
|
|
|
|
|
|
|
|
|
|
statePart.select(undefined, { signal: controller.signal }).subscribe({
|
|
|
|
|
next: (v) => values.push(v),
|
|
|
|
|
complete: () => { completed = true; },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(values.length).toBeGreaterThanOrEqual(1);
|
|
|
|
|
|
|
|
|
|
controller.abort();
|
|
|
|
|
// Give microtask time
|
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 10));
|
|
|
|
|
|
|
|
|
|
expect(completed).toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('select with pre-aborted signal should complete immediately', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
controller.abort();
|
|
|
|
|
|
|
|
|
|
let completed = false;
|
|
|
|
|
statePart.select(undefined, { signal: controller.signal }).subscribe({
|
|
|
|
|
complete: () => { completed = true; },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 10));
|
|
|
|
|
expect(completed).toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('waitUntilPresent should reject when AbortSignal fires', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 0,
|
|
|
|
|
nested: { data: '' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
|
|
|
|
const promise = statePart.waitUntilPresent(
|
|
|
|
|
(s) => s.value > 100 ? s : undefined as any,
|
|
|
|
|
{ signal: controller.signal }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Abort before the condition can be met
|
|
|
|
|
setTimeout(() => controller.abort(), 20);
|
|
|
|
|
|
|
|
|
|
let error: Error | null = null;
|
|
|
|
|
try {
|
|
|
|
|
await promise;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error = e as Error;
|
|
|
|
|
}
|
|
|
|
|
expect(error).not.toBeNull();
|
|
|
|
|
expect(error?.message).toEqual('Aborted');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('waitUntilPresent should still work with numeric timeout (backward compat)', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 42,
|
|
|
|
|
nested: { data: 'present' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
const result = await statePart.waitUntilPresent(undefined, 5000);
|
|
|
|
|
expect(result.value).toEqual(42);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
// Middleware tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
tap.test('middleware should transform state', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
statePart.addMiddleware((newState, oldState) => {
|
|
|
|
|
return { ...newState, nested: { data: newState.nested.data.toUpperCase() } };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await statePart.setState({ value: 2, nested: { data: 'hello' } });
|
|
|
|
|
expect(statePart.getState().nested.data).toEqual('HELLO');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('middleware should reject state changes on throw', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
statePart.addMiddleware((newState) => {
|
|
|
|
|
if (newState.value < 0) {
|
|
|
|
|
throw new Error('Value must be non-negative');
|
|
|
|
|
}
|
|
|
|
|
return newState;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let error: Error | null = null;
|
|
|
|
|
try {
|
|
|
|
|
await statePart.setState({ value: -1, nested: { data: 'bad' } });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
error = e as Error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(error).not.toBeNull();
|
|
|
|
|
expect(error?.message).toEqual('Value must be non-negative');
|
|
|
|
|
// State should be unchanged
|
|
|
|
|
expect(statePart.getState().value).toEqual(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('multiple middlewares should run in order', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
const order: number[] = [];
|
|
|
|
|
|
|
|
|
|
statePart.addMiddleware((newState) => {
|
|
|
|
|
order.push(1);
|
|
|
|
|
return { ...newState, value: newState.value + 10 };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
statePart.addMiddleware((newState) => {
|
|
|
|
|
order.push(2);
|
|
|
|
|
return { ...newState, value: newState.value * 2 };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await statePart.setState({ value: 5, nested: { data: 'test' } });
|
|
|
|
|
expect(order).toEqual([1, 2]);
|
|
|
|
|
// (5 + 10) * 2 = 30
|
|
|
|
|
expect(statePart.getState().value).toEqual(30);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('middleware removal should work', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
const remove = statePart.addMiddleware((newState) => {
|
|
|
|
|
return { ...newState, value: newState.value * 100 };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await statePart.setState({ value: 2, nested: { data: 'test' } });
|
|
|
|
|
expect(statePart.getState().value).toEqual(200);
|
|
|
|
|
|
|
|
|
|
remove();
|
|
|
|
|
|
|
|
|
|
await statePart.setState({ value: 3, nested: { data: 'test' } });
|
|
|
|
|
expect(statePart.getState().value).toEqual(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
// Selector memoization tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
tap.test('select with same selector fn should return cached observable', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
const selector = (s: ITestState) => s.value;
|
|
|
|
|
const obs1 = statePart.select(selector);
|
|
|
|
|
const obs2 = statePart.select(selector);
|
|
|
|
|
expect(obs1).toEqual(obs2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('select with no args should return cached observable', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
const obs1 = statePart.select();
|
|
|
|
|
const obs2 = statePart.select();
|
|
|
|
|
expect(obs1).toEqual(obs2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('select with different selectors should return different observables', async () => {
|
|
|
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'initial' }
|
|
|
|
|
}, 'force');
|
|
|
|
|
|
|
|
|
|
const obs1 = statePart.select((s) => s.value);
|
|
|
|
|
const obs2 = statePart.select((s) => s.nested);
|
|
|
|
|
expect(obs1).not.toEqual(obs2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
// Batch update tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
tap.test('batch should defer notifications until complete', async () => {
|
|
|
|
|
type TBatchParts = 'partA' | 'partB';
|
|
|
|
|
const state = new smartstate.Smartstate<TBatchParts>();
|
|
|
|
|
const partA = await state.getStatePart<ITestState>('partA', {
|
|
|
|
|
value: 1,
|
|
|
|
|
nested: { data: 'a' }
|
|
|
|
|
});
|
|
|
|
|
const partB = await state.getStatePart<ITestState>('partB', {
|
|
|
|
|
value: 2,
|
|
|
|
|
nested: { data: 'b' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const notificationsA: number[] = [];
|
|
|
|
|
const notificationsB: number[] = [];
|
|
|
|
|
|
|
|
|
|
partA.select((s) => s.value).subscribe((v) => notificationsA.push(v));
|
|
|
|
|
partB.select((s) => s.value).subscribe((v) => notificationsB.push(v));
|
|
|
|
|
|
|
|
|
|
// Reset after initial notifications
|
|
|
|
|
notificationsA.length = 0;
|
|
|
|
|
notificationsB.length = 0;
|
|
|
|
|
|
|
|
|
|
await state.batch(async () => {
|
|
|
|
|
await partA.setState({ value: 10, nested: { data: 'aa' } });
|
|
|
|
|
await partB.setState({ value: 20, nested: { data: 'bb' } });
|
|
|
|
|
|
|
|
|
|
// During batch, no notifications yet
|
|
|
|
|
expect(notificationsA).toHaveLength(0);
|
|
|
|
|
expect(notificationsB).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// After batch, both should have notified
|
|
|
|
|
expect(notificationsA).toContain(10);
|
|
|
|
|
expect(notificationsB).toContain(20);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('nested batches should only flush at outermost level', async () => {
|
|
|
|
|
type TBatchParts = 'nested';
|
|
|
|
|
const state = new smartstate.Smartstate<TBatchParts>();
|
|
|
|
|
const part = await state.getStatePart<ITestState>('nested', {
|
|
|
|
|
value: 0,
|
|
|
|
|
nested: { data: 'start' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const values: number[] = [];
|
|
|
|
|
part.select((s) => s.value).subscribe((v) => values.push(v));
|
|
|
|
|
values.length = 0;
|
|
|
|
|
|
|
|
|
|
await state.batch(async () => {
|
|
|
|
|
await part.setState({ value: 1, nested: { data: 'a' } });
|
|
|
|
|
|
|
|
|
|
await state.batch(async () => {
|
|
|
|
|
await part.setState({ value: 2, nested: { data: 'b' } });
|
|
|
|
|
// Still inside outer batch
|
|
|
|
|
expect(values).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Inner batch ended but outer batch still active
|
|
|
|
|
expect(values).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Now outer batch is done — should see final notification
|
|
|
|
|
expect(values.length).toBeGreaterThanOrEqual(1);
|
|
|
|
|
expect(values[values.length - 1]).toEqual(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
// Computed state tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
tap.test('computed should derive from multiple state parts', async () => {
|
|
|
|
|
type TComputedParts = 'first' | 'second';
|
|
|
|
|
const state = new smartstate.Smartstate<TComputedParts>();
|
|
|
|
|
const first = await state.getStatePart<{ count: number }>('first', { count: 5 });
|
|
|
|
|
const second = await state.getStatePart<{ count: number }>('second', { count: 10 });
|
|
|
|
|
|
|
|
|
|
const derived$ = state.computed(
|
|
|
|
|
[first, second],
|
|
|
|
|
(a, b) => a.count + b.count,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const values: number[] = [];
|
|
|
|
|
derived$.subscribe((v) => values.push(v));
|
|
|
|
|
|
|
|
|
|
expect(values).toContain(15);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('computed should update when a source changes', async () => {
|
|
|
|
|
type TComputedParts = 'x' | 'y';
|
|
|
|
|
const state = new smartstate.Smartstate<TComputedParts>();
|
|
|
|
|
const x = await state.getStatePart<{ n: number }>('x', { n: 1 });
|
|
|
|
|
const y = await state.getStatePart<{ n: number }>('y', { n: 2 });
|
|
|
|
|
|
|
|
|
|
const derived$ = state.computed(
|
|
|
|
|
[x, y],
|
|
|
|
|
(xState, yState) => xState.n * yState.n,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const values: number[] = [];
|
|
|
|
|
derived$.subscribe((v) => values.push(v));
|
|
|
|
|
|
|
|
|
|
// Initial: 1 * 2 = 2
|
|
|
|
|
expect(values[0]).toEqual(2);
|
|
|
|
|
|
|
|
|
|
await x.setState({ n: 5 });
|
|
|
|
|
|
|
|
|
|
// After update: 5 * 2 = 10
|
|
|
|
|
expect(values[values.length - 1]).toEqual(10);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('standalone computed function should work', async () => {
|
|
|
|
|
type TParts = 'a' | 'b';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const a = await state.getStatePart<{ val: string }>('a', { val: 'hello' });
|
|
|
|
|
const b = await state.getStatePart<{ val: string }>('b', { val: 'world' });
|
|
|
|
|
|
|
|
|
|
const derived$ = smartstate.computed(
|
|
|
|
|
[a, b],
|
|
|
|
|
(aState, bState) => `${aState.val} ${bState.val}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const values: string[] = [];
|
|
|
|
|
derived$.subscribe((v) => values.push(v));
|
|
|
|
|
|
|
|
|
|
expect(values[0]).toEqual('hello world');
|
|
|
|
|
|
|
|
|
|
await a.setState({ val: 'hi' });
|
|
|
|
|
expect(values[values.length - 1]).toEqual('hi world');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
// Context Protocol tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
tap.test('attachContextProvider should respond to context-request events', async () => {
|
|
|
|
|
// EventTarget and CustomEvent are available in Node 18+
|
|
|
|
|
if (typeof EventTarget === 'undefined') {
|
|
|
|
|
console.log('Skipping context test — EventTarget not available');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TParts = 'ctx';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const statePart = await state.getStatePart<{ theme: string }>('ctx', { theme: 'dark' });
|
|
|
|
|
|
|
|
|
|
const myContext = Symbol('test-context');
|
|
|
|
|
|
|
|
|
|
// Use an EventTarget as a mock element
|
|
|
|
|
const element = new EventTarget() as any as HTMLElement;
|
|
|
|
|
|
|
|
|
|
const cleanup = smartstate.attachContextProvider(element, {
|
|
|
|
|
context: myContext,
|
|
|
|
|
statePart,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let receivedValue: any = null;
|
|
|
|
|
|
|
|
|
|
// Dispatch a context-request event
|
|
|
|
|
const event = new CustomEvent('context-request', {
|
|
|
|
|
detail: {
|
|
|
|
|
context: myContext,
|
|
|
|
|
callback: (value: any) => { receivedValue = value; },
|
|
|
|
|
subscribe: false,
|
|
|
|
|
},
|
|
|
|
|
bubbles: true,
|
|
|
|
|
composed: true,
|
|
|
|
|
});
|
|
|
|
|
(element as any).dispatchEvent(event);
|
|
|
|
|
|
|
|
|
|
expect(receivedValue).toEqual({ theme: 'dark' });
|
|
|
|
|
|
|
|
|
|
cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('attachContextProvider should support subscriptions', async () => {
|
|
|
|
|
if (typeof EventTarget === 'undefined') {
|
|
|
|
|
console.log('Skipping context subscription test — EventTarget not available');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TParts = 'ctxSub';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const statePart = await state.getStatePart<{ count: number }>('ctxSub', { count: 0 });
|
|
|
|
|
|
|
|
|
|
const myContext = Symbol('sub-context');
|
|
|
|
|
const element = new EventTarget() as any as HTMLElement;
|
|
|
|
|
|
|
|
|
|
const cleanup = smartstate.attachContextProvider(element, {
|
|
|
|
|
context: myContext,
|
|
|
|
|
statePart,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const receivedValues: any[] = [];
|
|
|
|
|
let unsubFn: (() => void) | undefined;
|
|
|
|
|
|
|
|
|
|
const event = new CustomEvent('context-request', {
|
|
|
|
|
detail: {
|
|
|
|
|
context: myContext,
|
|
|
|
|
callback: (value: any, unsub?: () => void) => {
|
|
|
|
|
receivedValues.push(value);
|
|
|
|
|
if (unsub) unsubFn = unsub;
|
|
|
|
|
},
|
|
|
|
|
subscribe: true,
|
|
|
|
|
},
|
|
|
|
|
bubbles: true,
|
|
|
|
|
composed: true,
|
|
|
|
|
});
|
|
|
|
|
(element as any).dispatchEvent(event);
|
|
|
|
|
|
|
|
|
|
expect(receivedValues).toHaveLength(1);
|
|
|
|
|
expect(receivedValues[0]).toEqual({ count: 0 });
|
|
|
|
|
|
|
|
|
|
// Update state — should trigger subscription callback
|
|
|
|
|
await statePart.setState({ count: 42 });
|
|
|
|
|
|
|
|
|
|
// Give a tick for the subscription to fire
|
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 10));
|
|
|
|
|
|
|
|
|
|
expect(receivedValues.length).toBeGreaterThanOrEqual(2);
|
|
|
|
|
expect(receivedValues[receivedValues.length - 1]).toEqual({ count: 42 });
|
|
|
|
|
|
|
|
|
|
// Unsubscribe
|
|
|
|
|
expect(unsubFn).toBeDefined();
|
|
|
|
|
unsubFn!();
|
|
|
|
|
|
|
|
|
|
cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('attachContextProvider should ignore non-matching contexts', async () => {
|
|
|
|
|
if (typeof EventTarget === 'undefined') {
|
|
|
|
|
console.log('Skipping context mismatch test — EventTarget not available');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TParts = 'ctxMismatch';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const statePart = await state.getStatePart<{ v: number }>('ctxMismatch', { v: 1 });
|
|
|
|
|
|
|
|
|
|
const myContext = Symbol('my-context');
|
|
|
|
|
const otherContext = Symbol('other-context');
|
|
|
|
|
const element = new EventTarget() as any as HTMLElement;
|
|
|
|
|
|
|
|
|
|
const cleanup = smartstate.attachContextProvider(element, {
|
|
|
|
|
context: myContext,
|
|
|
|
|
statePart,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let called = false;
|
|
|
|
|
const event = new CustomEvent('context-request', {
|
|
|
|
|
detail: {
|
|
|
|
|
context: otherContext,
|
|
|
|
|
callback: () => { called = true; },
|
|
|
|
|
subscribe: false,
|
|
|
|
|
},
|
|
|
|
|
bubbles: true,
|
|
|
|
|
composed: true,
|
|
|
|
|
});
|
|
|
|
|
(element as any).dispatchEvent(event);
|
|
|
|
|
|
|
|
|
|
expect(called).toBeFalse();
|
|
|
|
|
|
|
|
|
|
cleanup();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-28 08:52:41 +00:00
|
|
|
// ============================
|
|
|
|
|
// Enterprise hardening tests
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10)', async () => {
|
|
|
|
|
type TParts = 'counter';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 });
|
|
|
|
|
|
|
|
|
|
const increment = counter.createAction<void>(async (statePart) => {
|
|
|
|
|
const current = statePart.getState();
|
|
|
|
|
return { count: current.count + 1 };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Fire 10 concurrent increments (no await)
|
|
|
|
|
const promises: Promise<any>[] = [];
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
promises.push(counter.dispatchAction(increment, undefined));
|
|
|
|
|
}
|
|
|
|
|
await Promise.all(promises);
|
|
|
|
|
|
|
|
|
|
expect(counter.getState().count).toEqual(10);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('concurrent setState should serialize (no lost updates)', async () => {
|
|
|
|
|
type TParts = 'concurrent';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const part = await state.getStatePart<{ values: number[] }>('concurrent', { values: [] });
|
|
|
|
|
|
|
|
|
|
const promises: Promise<any>[] = [];
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
promises.push(
|
|
|
|
|
part.setState({ values: [...(part.getState()?.values || []), i] })
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await Promise.all(promises);
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('dispose should complete the Subject and notify subscribers', async () => {
|
|
|
|
|
type TParts = 'disposable';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const part = await state.getStatePart<{ v: number }>('disposable', { v: 1 });
|
|
|
|
|
|
|
|
|
|
let completed = false;
|
|
|
|
|
part.select().subscribe({
|
|
|
|
|
complete: () => { completed = true; },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
part.dispose();
|
|
|
|
|
|
|
|
|
|
expect(completed).toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('falsy state {count: 0} should trigger notification', async () => {
|
|
|
|
|
type TParts = 'falsy';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const part = await state.getStatePart<{ count: number }>('falsy', { count: 0 });
|
|
|
|
|
|
|
|
|
|
const values: number[] = [];
|
|
|
|
|
part.select((s) => s.count).subscribe((v) => values.push(v));
|
|
|
|
|
|
|
|
|
|
// Initial value should include 0
|
|
|
|
|
expect(values).toContain(0);
|
|
|
|
|
|
|
|
|
|
await part.setState({ count: 0 });
|
|
|
|
|
// Should not duplicate since hash is the same, but the initial notification should have fired
|
|
|
|
|
expect(values.length).toBeGreaterThanOrEqual(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('waitUntilPresent should resolve for falsy non-null values like false', async () => {
|
|
|
|
|
type TParts = 'falsyWait';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const part = await state.getStatePart<{ flag: boolean }>('falsyWait', { flag: false });
|
|
|
|
|
|
|
|
|
|
const result = await part.waitUntilPresent((s) => s.flag as any, 2000);
|
|
|
|
|
// false is not null/undefined, so it should resolve
|
|
|
|
|
// Actually false IS falsy for `value !== undefined && value !== null` — false passes that check
|
|
|
|
|
expect(result).toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('batch re-entrancy: setState during flush should not deadlock', async () => {
|
|
|
|
|
type TParts = 'reentrant';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const part = await state.getStatePart<{ v: number }>('reentrant', { v: 0 });
|
|
|
|
|
|
|
|
|
|
let flushSetStateDone = false;
|
|
|
|
|
|
|
|
|
|
// Subscribe and trigger a setState during notification flush
|
|
|
|
|
part.select((s) => s.v).subscribe((v) => {
|
|
|
|
|
if (v === 1 && !flushSetStateDone) {
|
|
|
|
|
flushSetStateDone = true;
|
|
|
|
|
// Fire-and-forget setState during notification — should not deadlock
|
|
|
|
|
part.setState({ v: 2 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await state.batch(async () => {
|
|
|
|
|
await part.setState({ v: 1 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Wait for the fire-and-forget setState to complete
|
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 50));
|
|
|
|
|
|
|
|
|
|
expect(part.getState().v).toEqual(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('force mode should dispose old StatePart (Subject completes)', async () => {
|
|
|
|
|
type TParts = 'forceDispose';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
const old = await state.getStatePart<{ v: number }>('forceDispose', { v: 1 });
|
|
|
|
|
|
|
|
|
|
let oldCompleted = false;
|
|
|
|
|
old.select().subscribe({
|
|
|
|
|
complete: () => { oldCompleted = true; },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await state.getStatePart<{ v: number }>('forceDispose', { v: 2 }, 'force');
|
|
|
|
|
|
|
|
|
|
expect(oldCompleted).toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('getStatePart should accept 0 as initial value', async () => {
|
|
|
|
|
type TParts = 'zeroInit';
|
|
|
|
|
const state = new smartstate.Smartstate<TParts>();
|
|
|
|
|
|
|
|
|
|
// 0 is falsy but should be accepted as a valid initial value
|
|
|
|
|
const part = await state.getStatePart<number>('zeroInit', 0);
|
|
|
|
|
expect(part.getState()).toEqual(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-27 11:40:07 +00:00
|
|
|
export default tap.start();
|