feat(smartstate): Add middleware, computed, batching, selector memoization, AbortSignal support, and Web Component Context Protocol provider
This commit is contained in:
@@ -10,10 +10,13 @@ interface ITestState {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Init mode tests
|
||||
// ============================
|
||||
|
||||
tap.test('should handle soft init mode (default)', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
@@ -22,23 +25,20 @@ tap.test('should handle soft init mode (default)', async () => {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
// Second call should return existing
|
||||
|
||||
const statePart2 = await state.getStatePart<ITestState>('initTest');
|
||||
expect(statePart1).toEqual(statePart2);
|
||||
expect(statePart1 === statePart2).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should handle mandatory init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation should succeed
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('initTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
}, 'mandatory');
|
||||
expect(statePart1).toBeInstanceOf(smartstate.StatePart);
|
||||
|
||||
// Second call with mandatory should fail
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await state.getStatePart<ITestState>('initTest', {
|
||||
@@ -54,26 +54,24 @@ tap.test('should handle mandatory init mode', async () => {
|
||||
|
||||
tap.test('should handle force init mode', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
|
||||
// First creation
|
||||
|
||||
const statePart1 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
expect(statePart1.getState()?.value).toEqual(1);
|
||||
|
||||
// Force should create new state part
|
||||
|
||||
const statePart2 = await state.getStatePart<ITestState>('forceTest', {
|
||||
value: 2,
|
||||
nested: { data: 'forced' }
|
||||
}, 'force');
|
||||
expect(statePart2.getState()?.value).toEqual(2);
|
||||
expect(statePart1).not.toEqual(statePart2);
|
||||
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');
|
||||
@@ -86,13 +84,12 @@ tap.test('should handle missing initial state error', async () => {
|
||||
|
||||
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' }
|
||||
});
|
||||
|
||||
// Setting null should fail validation
|
||||
|
||||
let error: Error | null = null;
|
||||
try {
|
||||
await statePart.setState(null as any);
|
||||
@@ -106,20 +103,17 @@ tap.test('should handle state validation', async () => {
|
||||
tap.test('should handle undefined state in select', async () => {
|
||||
const state = new smartstate.Smartstate<TTestStateParts>();
|
||||
const statePart = new smartstate.StatePart<TTestStateParts, ITestState>('initTest');
|
||||
|
||||
// Select should filter out undefined states
|
||||
|
||||
const values: (ITestState | undefined)[] = [];
|
||||
statePart.select().subscribe(val => values.push(val));
|
||||
|
||||
// Initially undefined, should not emit
|
||||
|
||||
expect(values).toHaveLength(0);
|
||||
|
||||
// After setting state, should emit
|
||||
|
||||
await statePart.setState({
|
||||
value: 1,
|
||||
nested: { data: 'test' }
|
||||
});
|
||||
|
||||
|
||||
expect(values).toHaveLength(1);
|
||||
expect(values[0]).toEqual({
|
||||
value: 1,
|
||||
@@ -133,25 +127,506 @@ tap.test('should not notify on duplicate state', async () => {
|
||||
value: 1,
|
||||
nested: { data: 'initial' }
|
||||
});
|
||||
|
||||
|
||||
let notificationCount = 0;
|
||||
// Use select() to get initial value + changes
|
||||
statePart.select().subscribe(() => notificationCount++);
|
||||
|
||||
// Should have received initial state
|
||||
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
// Set same state multiple times
|
||||
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
await statePart.setState({ value: 1, nested: { data: 'initial' } });
|
||||
|
||||
// Should still be 1 (no new notifications for duplicate state)
|
||||
|
||||
expect(notificationCount).toEqual(1);
|
||||
|
||||
// Change state should notify
|
||||
|
||||
await statePart.setState({ value: 2, nested: { data: 'changed' } });
|
||||
expect(notificationCount).toEqual(2);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
// ============================
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user