633 lines
18 KiB
TypeScript
633 lines
18 KiB
TypeScript
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;
|
|
};
|
|
}
|
|
|
|
// ============================
|
|
// Init mode tests
|
|
// ============================
|
|
|
|
tap.test('should handle soft init mode (default)', async () => {
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
|
value: 1,
|
|
nested: { data: 'initial' }
|
|
});
|
|
expect(statePart1.getState()).toEqual({
|
|
value: 1,
|
|
nested: { data: 'initial' }
|
|
});
|
|
|
|
const statePart2 = await state.getStatePart<ITestState>('initTest');
|
|
expect(statePart1 === statePart2).toBeTrue();
|
|
});
|
|
|
|
tap.test('should handle mandatory init mode', async () => {
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
|
value: 1,
|
|
nested: { data: 'initial' }
|
|
}, 'mandatory');
|
|
expect(statePart1).toBeInstanceOf(smartstate.StatePart);
|
|
|
|
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>();
|
|
|
|
const statePart1 = await state.getStatePart<ITestState>('forceTest', {
|
|
value: 1,
|
|
nested: { data: 'initial' }
|
|
});
|
|
expect(statePart1.getState()?.value).toEqual(1);
|
|
|
|
const statePart2 = await state.getStatePart<ITestState>('forceTest', {
|
|
value: 2,
|
|
nested: { data: 'forced' }
|
|
}, 'force');
|
|
expect(statePart2.getState()?.value).toEqual(2);
|
|
expect(statePart1 === statePart2).toBeFalse();
|
|
});
|
|
|
|
tap.test('should handle missing initial state error', async () => {
|
|
const state = new smartstate.Smartstate<TTestStateParts>();
|
|
|
|
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>();
|
|
|
|
const statePart = await state.getStatePart<ITestState>('initTest', {
|
|
value: 1,
|
|
nested: { data: 'initial' }
|
|
});
|
|
|
|
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');
|
|
|
|
const values: (ITestState | undefined)[] = [];
|
|
statePart.select().subscribe(val => values.push(val));
|
|
|
|
expect(values).toHaveLength(0);
|
|
|
|
await statePart.setState({
|
|
value: 1,
|
|
nested: { data: 'test' }
|
|
});
|
|
|
|
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' }
|
|
});
|
|
|
|
let notificationCount = 0;
|
|
statePart.select().subscribe(() => notificationCount++);
|
|
|
|
expect(notificationCount).toEqual(1);
|
|
|
|
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
|
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
|
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
|
|
|
expect(notificationCount).toEqual(1);
|
|
|
|
await statePart.setState({ value: 2, nested: { data: 'changed' } });
|
|
expect(notificationCount).toEqual(2);
|
|
});
|
|
|
|
// ============================
|
|
// 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();
|
|
});
|
|
|
|
export default tap.start();
|