@nocobase/flow-engine
Version:
A standalone flow engine for NocoBase, managing workflows, models, and actions.
1,379 lines (1,095 loc) • 94.4 kB
text/typescript
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { autorun, observable, reaction, reactive } from '@nocobase/flow-engine';
import { APIClient } from '@nocobase/sdk';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import { FlowModelRenderer } from '../../components/FlowModelRenderer';
import { FlowEngine } from '../../flowEngine';
import type { DefaultStructure, FlowDefinitionOptions, FlowModelOptions, ModelConstructor } from '../../types';
import { FlowExitException } from '../../utils';
import { FlowExitAllException } from '../../utils/exceptions';
import { defineFlow, FlowModel, ModelRenderMode } from '../flowModel';
import { ForkFlowModel } from '../forkFlowModel';
// 全局处理测试中的未处理 Promise rejection
const originalUnhandledRejection = process.listeners('unhandledRejection');
process.removeAllListeners('unhandledRejection');
process.on('unhandledRejection', (reason, promise) => {
// 如果是我们测试中故意抛出的错误,就忽略它
if (reason instanceof Error && reason.message === 'Test error') {
return;
}
// 其他错误仍然需要处理
originalUnhandledRejection.forEach((listener) => {
if (typeof listener === 'function') {
listener(reason, promise);
}
});
});
// // Mock dependencies
// vi.mock('uid/secure', () => ({
// uid: vi.fn(() => 'mock-uid-' + Math.random().toString(36).substring(2, 11)),
// }));
// vi.mock('../forkFlowModel', () => ({
// ForkFlowModel: vi.fn().mockImplementation(function (master: any, localProps: any, forkId: number) {
// const instance = {
// master,
// localProps,
// forkId,
// setProps: vi.fn(),
// dispose: vi.fn(),
// disposed: false,
// };
// Object.setPrototypeOf(instance, ForkFlowModel.prototype);
// return instance;
// }),
// }));
// vi.mock('../../components/settings/wrappers/contextual/StepSettingsDialog', () => ({
// openStepSettingsDialog: vi.fn(),
// }));
// vi.mock('../../components/settings/wrappers/contextual/StepRequiredSettingsDialog', () => ({
// openRequiredParamsStepFormDialog: vi.fn(),
// }));
// vi.mock('lodash', async () => {
// const actual = await vi.importActual('lodash');
// return {
// ...actual,
// debounce: vi.fn((fn) => fn),
// };
// });
// Helper functions
const createMockFlowEngine = (): FlowEngine => {
return new FlowEngine();
};
const createBasicFlowDefinition = (overrides: Partial<FlowDefinitionOptions> = {}): FlowDefinitionOptions => ({
key: 'testFlow',
steps: {
step1: {
handler: vi.fn().mockResolvedValue('step1-result'),
},
step2: {
handler: vi.fn().mockResolvedValue('step2-result'),
},
},
...overrides,
});
const createAutoFlowDefinition = (overrides: Partial<FlowDefinitionOptions> = {}): FlowDefinitionOptions => ({
key: 'autoFlow',
sort: 1,
steps: {
autoStep: {
handler: vi.fn().mockResolvedValue('auto-result'),
},
},
...overrides,
});
const createEventFlowDefinition = (
eventName: string,
overrides: Partial<FlowDefinitionOptions> = {},
): FlowDefinitionOptions => ({
key: `${eventName}Flow`,
on: { eventName },
steps: {
eventStep: {
handler: vi.fn().mockResolvedValue('event-result'),
},
},
...overrides,
});
const createErrorFlowDefinition = (
errorMessage = 'Test error',
overrides: Partial<FlowDefinitionOptions> = {},
): FlowDefinitionOptions => ({
key: 'errorFlow',
steps: {
errorStep: {
handler: vi.fn().mockRejectedValue(new Error(errorMessage)),
},
},
...overrides,
});
// Test setup
let flowEngine: FlowEngine;
let modelOptions: FlowModelOptions;
beforeEach(() => {
flowEngine = createMockFlowEngine();
modelOptions = {
uid: 'test-model-uid',
flowEngine,
stepParams: { testFlow: { step1: { param1: 'value1' } } },
sortIndex: 0,
subModels: {},
};
vi.clearAllMocks();
});
describe('FlowModel', () => {
// ==================== CONSTRUCTOR & INITIALIZATION ====================
describe('Constructor & Initialization', () => {
test('should create instance with basic options', () => {
const model = new FlowModel(modelOptions);
expect(model.uid).toBe(modelOptions.uid);
expect(model.stepParams).toEqual(expect.objectContaining(modelOptions.stepParams));
expect(model.flowEngine).toBe(modelOptions.flowEngine);
expect(model.sortIndex).toBe(modelOptions.sortIndex);
});
test('should generate uid if not provided', () => {
const options = { ...modelOptions, uid: undefined };
const model = new FlowModel(options);
expect(model.uid).toBeDefined();
expect(typeof model.uid).toBe('string');
expect(model.uid.length).toBeGreaterThan(0);
});
test('should return existing instance if already exists in FlowEngine', () => {
const firstInstance = new FlowModel(modelOptions);
flowEngine.getModel = vi.fn().mockReturnValue(firstInstance);
const secondInstance = new FlowModel(modelOptions);
expect(secondInstance).toBe(firstInstance);
expect(flowEngine.getModel).toHaveBeenCalledWith(modelOptions.uid);
});
test('should initialize with default values when options are minimal', () => {
const model = new FlowModel({ flowEngine } as FlowModelOptions);
expect(model.props).toBeDefined();
expect(model.stepParams).toBeDefined();
expect(model.subModels).toBeDefined();
expect(model.forks).toBeInstanceOf(Set);
expect(model.forks.size).toBe(0);
});
test('should throw error when flowEngine is missing', () => {
expect(() => {
new FlowModel({} as any);
}).toThrow('FlowModel must be initialized with a FlowEngine instance.');
});
test('should initialize emitter', () => {
const model = new FlowModel(modelOptions);
expect(model.emitter).toBeDefined();
expect(typeof model.emitter.on).toBe('function');
expect(typeof model.emitter.emit).toBe('function');
});
});
// ==================== PROPERTIES MANAGEMENT ====================
describe('Properties Management', () => {
let model: FlowModel;
beforeEach(() => {
model = new FlowModel(modelOptions);
});
describe('setProps', () => {
test('should merge props correctly', () => {
const initialProps = { a: 1, b: 2 };
model.setProps(initialProps);
expect(model.props).toEqual(expect.objectContaining(initialProps));
const additionalProps = { b: 3, c: 4 };
model.setProps(additionalProps);
expect(model.props).toEqual(expect.objectContaining({ a: 1, b: 3, c: 4 }));
});
test('should handle null and undefined props', () => {
const originalProps = { ...model.props };
model.setProps(null as any);
expect(model.props).toEqual(originalProps);
model.setProps({ test: 'value' });
model.setProps(undefined as any);
expect(model.props).toEqual(expect.objectContaining({ test: 'value' }));
});
test('should handle nested objects', () => {
const nestedProps = {
user: { name: 'John', age: 30 },
settings: { theme: 'dark', lang: 'en' },
};
model.setProps(nestedProps);
expect(model.props).toEqual(expect.objectContaining(nestedProps));
model.setProps({ user: { name: 'Jane', email: 'jane@example.com' } });
expect(model.props.user).toEqual({ name: 'Jane', email: 'jane@example.com' });
expect(model.props.settings).toEqual({ theme: 'dark', lang: 'en' });
});
test.skip('should be reactive', async () => {
reaction(
() => model.props.foo, // 观察的字段
(newProps, oldProps) => {
console.log('Props changed from', oldProps, 'to', newProps);
},
);
model.props.foo = 'bar';
model.props.foo = 'baz';
model.setProps({ foo: 'bar' });
model.setProps({ foo: 'baz' });
});
});
describe('setStepParams', () => {
test('should merge step parameters correctly', () => {
const initialParams = {
flow1: { step1: { param1: 'value1' } },
flow2: { step2: { param2: 'value2' } },
};
model.setStepParams(initialParams);
expect(model.stepParams).toEqual(expect.objectContaining(initialParams));
const additionalParams = {
flow1: { step1: { param1: 'updated', param3: 'value3' } },
flow3: { step3: { param4: 'value4' } },
};
model.setStepParams(additionalParams);
expect(model.stepParams).toEqual(
expect.objectContaining({
flow1: { step1: { param1: 'updated', param3: 'value3' } },
flow2: { step2: { param2: 'value2' } },
flow3: { step3: { param4: 'value4' } },
}),
);
});
test('should handle empty and null parameters', () => {
const originalParams = { ...model.stepParams };
model.setStepParams({});
expect(model.stepParams).toEqual(originalParams);
model.setStepParams(null as any);
expect(model.stepParams).toEqual(originalParams);
});
});
});
// ==================== FLOW MANAGEMENT ====================
describe('Flow Management', () => {
// TODO: design and add tests for flows management
let TestFlowModel: typeof FlowModel;
beforeEach(() => {
TestFlowModel = class extends FlowModel<any> {};
});
it('placeholder test - should create FlowModel subclass', () => {
expect(TestFlowModel).toBeDefined();
expect(TestFlowModel.prototype).toBeInstanceOf(FlowModel);
});
});
// ==================== FLOW EXECUTION ====================
describe('Flow Execution', () => {
let model: FlowModel;
let TestFlowModel: typeof FlowModel<DefaultStructure>;
beforeEach(() => {
TestFlowModel = class extends FlowModel {};
model = new TestFlowModel(modelOptions);
});
describe('applyFlow', () => {
test('should throw error for non-existent flow', async () => {
await expect(model.applyFlow('nonExistentFlow')).rejects.toThrow("Flow 'nonExistentFlow' not found.");
});
test('should throw error when FlowEngine not available', async () => {
// Since FlowModel constructor now requires flowEngine, we test the error at construction time
expect(() => {
new TestFlowModel({ uid: 'test' } as any);
}).toThrow('FlowModel must be initialized with a FlowEngine instance.');
});
test('should handle FlowExitException correctly', async () => {
const exitFlow: FlowDefinitionOptions = {
key: 'exitFlow',
steps: {
step1: {
handler: (ctx) => {
ctx.exit();
return 'should-not-reach';
},
},
step2: {
handler: vi.fn().mockReturnValue('step2-result'),
},
},
};
TestFlowModel.registerFlow(exitFlow);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const result = await model.applyFlow('exitFlow');
expect(result).toEqual({});
expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
consoleSpy.mockRestore();
});
test('should handle FlowExitException correctly', async () => {
const exitFlow: FlowDefinitionOptions = {
key: 'exitFlow',
steps: {
step1: {
handler: (ctx) => {
ctx.exit();
return 'should-not-reach';
},
},
step2: {
handler: vi.fn().mockReturnValue('step2-result'),
},
},
};
const exitFlow2: FlowDefinitionOptions = {
key: 'exitFlow2',
steps: {
step2: {
handler: vi.fn().mockReturnValue('step2-result'),
},
},
};
TestFlowModel.registerFlow(exitFlow);
TestFlowModel.registerFlow(exitFlow2);
const loggerSpy = vi.spyOn(model.context.logger, 'info').mockImplementation(() => {});
await model.applyAutoFlows();
expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
expect(exitFlow2.steps.step2.handler).toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowEngine]'));
loggerSpy.mockRestore();
});
test('should handle FlowExitAllException correctly', async () => {
const exitFlow: FlowDefinitionOptions = {
key: 'exitFlow',
steps: {
step1: {
handler: (ctx) => {
ctx.exitAll();
return 'should-not-reach';
},
},
step2: {
handler: vi.fn().mockReturnValue('step2-result'),
},
},
};
const exitFlow2: FlowDefinitionOptions = {
key: 'exitFlow2',
steps: {
step2: {
handler: vi.fn().mockReturnValue('step2-result'),
},
},
};
TestFlowModel.registerFlow(exitFlow);
TestFlowModel.registerFlow(exitFlow2);
const loggerSpy = vi.spyOn(model.context.logger, 'info').mockImplementation(() => {});
await model.applyAutoFlows();
expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
expect(exitFlow2.steps.step2.handler).not.toHaveBeenCalled();
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowEngine]'));
loggerSpy.mockRestore();
});
test('should handle FlowExitAllException correctly', async () => {
const exitFlow: FlowDefinitionOptions = {
key: 'exitFlow',
steps: {
step1: {
handler: (ctx) => {
ctx.exitAll();
return 'should-not-reach';
},
},
step2: {
handler: vi.fn().mockReturnValue('step2-result'),
},
},
};
TestFlowModel.registerFlow(exitFlow);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const result = await model.applyFlow('exitFlow');
expect(result).toBeInstanceOf(FlowExitAllException);
expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
consoleSpy.mockRestore();
});
test('should propagate step execution errors', async () => {
const errorFlow = createErrorFlowDefinition('Step execution failed');
TestFlowModel.registerFlow(errorFlow);
await expect(model.applyFlow(errorFlow.key)).rejects.toThrow('Step execution failed');
});
test('should use action when step references registered action', async () => {
const actionHandler = vi.fn().mockResolvedValue('action-result');
model.flowEngine.getAction = vi.fn().mockReturnValue({
handler: actionHandler,
defaultParams: { actionParam: 'actionValue' },
});
const actionFlow: FlowDefinitionOptions = {
key: 'actionFlow',
steps: {
actionStep: {
use: 'testAction',
defaultParams: { stepParam: 'stepValue' },
},
},
};
TestFlowModel.registerFlow(actionFlow);
const result = await model.applyFlow('actionFlow');
expect(model.flowEngine.getAction).toHaveBeenCalledWith('testAction');
expect(actionHandler).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
actionParam: 'actionValue',
stepParam: 'stepValue',
}),
);
expect(result.actionStep).toBe('action-result');
});
test('should skip step when action not found', async () => {
model.flowEngine.getAction = vi.fn().mockReturnValue(null);
const loggerSpy = vi.spyOn(model.context.logger, 'error').mockImplementation(() => {});
const actionFlow: FlowDefinitionOptions = {
key: 'actionFlow',
steps: {
missingActionStep: {
use: 'nonExistentAction',
},
},
};
TestFlowModel.registerFlow(actionFlow);
const result = await model.applyFlow('actionFlow');
expect(result).toEqual({});
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining("Action 'nonExistentAction' not found"));
loggerSpy.mockRestore();
});
});
describe('applyAutoFlows', () => {
test('should execute all auto flows', async () => {
const autoFlow1 = { ...createAutoFlowDefinition(), key: 'auto1', sort: 1 };
const autoFlow2 = { ...createAutoFlowDefinition(), key: 'auto2', sort: 2 };
const manualFlow = { ...createBasicFlowDefinition(), manual: true }; // Mark as manual flow
TestFlowModel.registerFlow(autoFlow1);
TestFlowModel.registerFlow(autoFlow2);
TestFlowModel.registerFlow(manualFlow);
const results = await model.applyAutoFlows();
expect(results).toHaveLength(2);
expect(autoFlow1.steps.autoStep.handler).toHaveBeenCalled();
expect(autoFlow2.steps.autoStep.handler).toHaveBeenCalled();
expect(manualFlow.steps.step1.handler).not.toHaveBeenCalled();
});
test('should execute auto flows in sort order', async () => {
const executionOrder: string[] = [];
const autoFlow1 = {
key: 'auto1',
sort: 3,
steps: {
step: {
handler: () => {
executionOrder.push('auto1');
return 'result1';
},
},
},
};
const autoFlow2 = {
key: 'auto2',
sort: 1,
steps: {
step: {
handler: () => {
executionOrder.push('auto2');
return 'result2';
},
},
},
};
const autoFlow3 = {
key: 'auto3',
sort: 2,
steps: {
step: {
handler: () => {
executionOrder.push('auto3');
return 'result3';
},
},
},
};
TestFlowModel.registerFlow(autoFlow1);
TestFlowModel.registerFlow(autoFlow2);
TestFlowModel.registerFlow(autoFlow3);
await model.applyAutoFlows();
expect(executionOrder).toEqual(['auto2', 'auto3', 'auto1']);
});
test('should no results when no auto flows found', async () => {
const results = await model.applyAutoFlows();
expect(results).toEqual([]);
// Note: Log output may be captured in stderr, not console.log
});
describe('lifecycle hooks', () => {
let TestFlowModelWithHooks: any;
let beforeHookSpy: any;
let afterHookSpy: any;
let errorHookSpy: any;
beforeEach(() => {
beforeHookSpy = vi.fn();
afterHookSpy = vi.fn();
errorHookSpy = vi.fn();
TestFlowModelWithHooks = class extends TestFlowModel {
async onBeforeAutoFlows(inputArgs?: Record<string, any>) {
beforeHookSpy(inputArgs);
}
async onAfterAutoFlows(results: any[], inputArgs?: Record<string, any>) {
afterHookSpy(results, inputArgs);
}
async onAutoFlowsError(error: Error, inputArgs?: Record<string, any>) {
errorHookSpy(error, inputArgs);
}
};
});
test('should call lifecycle hooks in correct order', async () => {
const autoFlow = createAutoFlowDefinition();
TestFlowModelWithHooks.registerFlow(autoFlow);
const modelWithHooks = new TestFlowModelWithHooks(modelOptions);
const inputArgs = { test: 'value' };
const results = await modelWithHooks.applyAutoFlows(inputArgs);
// Verify hooks were called
expect(beforeHookSpy).toHaveBeenCalledTimes(1);
expect(afterHookSpy).toHaveBeenCalledTimes(1);
expect(errorHookSpy).not.toHaveBeenCalled();
// Verify hook parameters
expect(beforeHookSpy).toHaveBeenCalledWith(inputArgs);
expect(afterHookSpy).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ autoStep: 'auto-result' })]),
inputArgs,
);
});
test('should allow onBeforeAutoFlows to terminate flow via ctx.exit()', async () => {
const autoFlow1 = { ...createAutoFlowDefinition(), key: 'auto1' };
const autoFlow2 = { ...createAutoFlowDefinition(), key: 'auto2' };
const TestFlowModelWithExitHooks = class extends TestFlowModel {
async onBeforeAutoFlows(inputArgs?: Record<string, any>) {
beforeHookSpy(inputArgs);
throw new FlowExitException('autoFlows', this.uid);
}
async onAfterAutoFlows(results: any[], inputArgs?: Record<string, any>) {
afterHookSpy(results, inputArgs);
}
async onAutoFlowsError(error: Error, inputArgs?: Record<string, any>) {
errorHookSpy(error, inputArgs);
}
};
// 在正确的类上注册流程
TestFlowModelWithExitHooks.registerFlow(autoFlow1);
TestFlowModelWithExitHooks.registerFlow(autoFlow2);
const modelWithHooks = new TestFlowModelWithExitHooks(modelOptions);
const results = await modelWithHooks.applyAutoFlows();
// Should have called onBeforeAutoFlows but not onAfterAutoFlows
expect(beforeHookSpy).toHaveBeenCalledTimes(1);
expect(afterHookSpy).not.toHaveBeenCalled();
expect(errorHookSpy).not.toHaveBeenCalled();
// Should return empty results since flow was terminated early
expect(results).toEqual([]);
// Auto flows should not have been executed
expect(autoFlow1.steps.autoStep.handler).not.toHaveBeenCalled();
expect(autoFlow2.steps.autoStep.handler).not.toHaveBeenCalled();
});
test('should call onAutoFlowsError when flow execution fails', async () => {
const errorFlow = {
key: 'errorFlow',
steps: {
errorStep: {
handler: vi.fn().mockImplementation(() => {
throw new Error('Test error');
}),
},
},
};
TestFlowModelWithHooks.registerFlow(errorFlow);
const modelWithHooks = new TestFlowModelWithHooks(modelOptions);
// 测试错误处理钩子功能
await expect(modelWithHooks.applyAutoFlows()).rejects.toThrow('Test error');
// Verify hooks were called
expect(beforeHookSpy).toHaveBeenCalledTimes(1);
expect(afterHookSpy).not.toHaveBeenCalled();
expect(errorHookSpy).toHaveBeenCalledTimes(1);
// Verify error hook parameters
expect(errorHookSpy).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Test error',
}),
undefined, // inputArgs was not provided
);
});
test('should provide access to step results in onAfterAutoFlows', async () => {
const autoFlow1 = { ...createAutoFlowDefinition(), key: 'auto1' };
const autoFlow2 = { ...createAutoFlowDefinition(), key: 'auto2' };
TestFlowModelWithHooks.registerFlow(autoFlow1);
TestFlowModelWithHooks.registerFlow(autoFlow2);
const modelWithHooks = new TestFlowModelWithHooks(modelOptions);
await modelWithHooks.applyAutoFlows();
expect(afterHookSpy).toHaveBeenCalledTimes(1);
const [results, inputArgs] = afterHookSpy.mock.calls[0];
// Verify results array contains results from both flows
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ autoStep: 'auto-result' });
expect(results[1]).toEqual({ autoStep: 'auto-result' });
// Verify inputArgs is undefined since none was provided
expect(inputArgs).toBeUndefined();
});
});
});
describe('dispatchEvent', () => {
test('should execute event-triggered flows', async () => {
const eventFlow = createEventFlowDefinition('testEvent');
TestFlowModel.registerFlow(eventFlow);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
model.dispatchEvent('testEvent', { data: 'payload' });
// Use a more reliable approach than arbitrary timeout
await new Promise((resolve) => setTimeout(resolve, 0));
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[FlowModel] dispatchEvent: uid=test-model-uid, event=testEvent'),
);
expect(eventFlow.steps.eventStep.handler).toHaveBeenCalledWith(
expect.objectContaining({
inputArgs: { data: 'payload' },
}),
expect.any(Object),
);
} finally {
consoleSpy.mockRestore();
}
});
test('should handle multiple flows for same event', async () => {
const eventFlow1 = { ...createEventFlowDefinition('sharedEvent'), key: 'event1' };
const eventFlow2 = { ...createEventFlowDefinition('sharedEvent'), key: 'event2' };
TestFlowModel.registerFlow(eventFlow1);
TestFlowModel.registerFlow(eventFlow2);
model.dispatchEvent('sharedEvent');
// Use a more reliable approach than arbitrary timeout
await new Promise((resolve) => setTimeout(resolve, 0));
expect(eventFlow1.steps.eventStep.handler).toHaveBeenCalled();
expect(eventFlow2.steps.eventStep.handler).toHaveBeenCalled();
});
describe('debounce functionality', () => {
test('should use debounced dispatch when debounce option is true', async () => {
const eventFlow = createEventFlowDefinition('debouncedEvent');
TestFlowModel.registerFlow(eventFlow);
const _dispatchEventSpy = vi.spyOn(model as any, '_dispatchEvent');
const _dispatchEventWithDebounceSpy = vi.spyOn(model as any, '_dispatchEventWithDebounce');
// Test with debounce enabled
await model.dispatchEvent('debouncedEvent', { data: 'test' }, { debounce: true });
expect(_dispatchEventWithDebounceSpy).toHaveBeenCalledWith('debouncedEvent', { data: 'test' });
expect(_dispatchEventSpy).not.toHaveBeenCalled();
_dispatchEventSpy.mockRestore();
_dispatchEventWithDebounceSpy.mockRestore();
});
test('should use normal dispatch when debounce option is false', async () => {
const eventFlow = createEventFlowDefinition('normalEvent');
TestFlowModel.registerFlow(eventFlow);
const _dispatchEventSpy = vi.spyOn(model as any, '_dispatchEvent');
const _dispatchEventWithDebounceSpy = vi.spyOn(model as any, '_dispatchEventWithDebounce');
// Test with debounce disabled
await model.dispatchEvent('normalEvent', { data: 'test' }, { debounce: false });
expect(_dispatchEventSpy).toHaveBeenCalledWith('normalEvent', { data: 'test' });
expect(_dispatchEventWithDebounceSpy).not.toHaveBeenCalled();
_dispatchEventSpy.mockRestore();
_dispatchEventWithDebounceSpy.mockRestore();
});
test('should use normal dispatch when debounce option is not provided', async () => {
const eventFlow = createEventFlowDefinition('defaultEvent');
TestFlowModel.registerFlow(eventFlow);
const _dispatchEventSpy = vi.spyOn(model as any, '_dispatchEvent');
const _dispatchEventWithDebounceSpy = vi.spyOn(model as any, '_dispatchEventWithDebounce');
// Test without debounce option
await model.dispatchEvent('defaultEvent', { data: 'test' });
expect(_dispatchEventSpy).toHaveBeenCalledWith('defaultEvent', { data: 'test' });
expect(_dispatchEventWithDebounceSpy).not.toHaveBeenCalled();
_dispatchEventSpy.mockRestore();
_dispatchEventWithDebounceSpy.mockRestore();
});
test('should use normal dispatch when options is undefined', async () => {
const eventFlow = createEventFlowDefinition('undefinedOptionsEvent');
TestFlowModel.registerFlow(eventFlow);
const _dispatchEventSpy = vi.spyOn(model as any, '_dispatchEvent');
const _dispatchEventWithDebounceSpy = vi.spyOn(model as any, '_dispatchEventWithDebounce');
// Test with undefined options
await model.dispatchEvent('undefinedOptionsEvent', { data: 'test' }, undefined);
expect(_dispatchEventSpy).toHaveBeenCalledWith('undefinedOptionsEvent', { data: 'test' });
expect(_dispatchEventWithDebounceSpy).not.toHaveBeenCalled();
_dispatchEventSpy.mockRestore();
_dispatchEventWithDebounceSpy.mockRestore();
});
test('should debounce multiple rapid calls when debounce is true', async () => {
const eventFlow = createEventFlowDefinition('rapidEvent');
TestFlowModel.registerFlow(eventFlow);
const handlerSpy = eventFlow.steps.eventStep.handler as any;
handlerSpy.mockClear();
// Make multiple rapid calls with debounce enabled
model.dispatchEvent('rapidEvent', { call: 1 }, { debounce: true });
model.dispatchEvent('rapidEvent', { call: 2 }, { debounce: true });
model.dispatchEvent('rapidEvent', { call: 3 }, { debounce: true });
// Wait for debounce timeout (100ms + buffer)
await new Promise((resolve) => setTimeout(resolve, 150));
// Only the last call should be executed due to debouncing
expect(handlerSpy).toHaveBeenCalledTimes(1);
expect(handlerSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
inputArgs: { call: 3 },
}),
expect.any(Object),
);
});
test('should not debounce calls when debounce is false', async () => {
const eventFlow = createEventFlowDefinition('nonDebouncedEvent');
TestFlowModel.registerFlow(eventFlow);
const handlerSpy = eventFlow.steps.eventStep.handler as any;
handlerSpy.mockClear();
// Make multiple rapid calls with debounce disabled
await model.dispatchEvent('nonDebouncedEvent', { call: 1 }, { debounce: false });
await model.dispatchEvent('nonDebouncedEvent', { call: 2 }, { debounce: false });
await model.dispatchEvent('nonDebouncedEvent', { call: 3 }, { debounce: false });
// All calls should be executed
expect(handlerSpy).toHaveBeenCalledTimes(3);
expect(handlerSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
inputArgs: { call: 1 },
}),
expect.any(Object),
);
expect(handlerSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
inputArgs: { call: 2 },
}),
expect.any(Object),
);
expect(handlerSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
inputArgs: { call: 3 },
}),
expect.any(Object),
);
});
test('should handle mixed debounced and non-debounced calls correctly', async () => {
const eventFlow = createEventFlowDefinition('mixedEvent');
TestFlowModel.registerFlow(eventFlow);
const handlerSpy = eventFlow.steps.eventStep.handler as any;
handlerSpy.mockClear();
// Make a non-debounced call
await model.dispatchEvent('mixedEvent', { type: 'immediate' }, { debounce: false });
// Make rapid debounced calls
model.dispatchEvent('mixedEvent', { type: 'debounced', call: 1 }, { debounce: true });
model.dispatchEvent('mixedEvent', { type: 'debounced', call: 2 }, { debounce: true });
// Wait for debounce timeout
await new Promise((resolve) => setTimeout(resolve, 150));
// Should have immediate call + one debounced call
expect(handlerSpy).toHaveBeenCalledTimes(2);
expect(handlerSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
inputArgs: { type: 'immediate' },
}),
expect.any(Object),
);
expect(handlerSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
inputArgs: { type: 'debounced', call: 2 },
}),
expect.any(Object),
);
});
test('should pass correct arguments to debounced function', async () => {
const eventFlow = createEventFlowDefinition('argumentsEvent');
TestFlowModel.registerFlow(eventFlow);
const handlerSpy = eventFlow.steps.eventStep.handler as any;
handlerSpy.mockClear();
const inputArgs = {
userId: 123,
action: 'click',
timestamp: Date.now(),
metadata: { source: 'test' },
};
model.dispatchEvent('argumentsEvent', inputArgs, { debounce: true });
// Wait for debounce timeout
await new Promise((resolve) => setTimeout(resolve, 150));
expect(handlerSpy).toHaveBeenCalledTimes(1);
expect(handlerSpy).toHaveBeenCalledWith(
expect.objectContaining({
inputArgs,
}),
expect.any(Object),
);
});
});
});
});
// ==================== RELATIONSHIPS ====================
describe('Relationships', () => {
let model: FlowModel;
beforeEach(() => {
model = new FlowModel(modelOptions);
});
describe('parent-child relationships', () => {
test('should set parent correctly', () => {
const parent = new FlowModel({ ...modelOptions, uid: 'parent' });
model.setParent(parent);
expect(model.parent).toBe(parent);
});
test('should not allow setting parent to null', () => {
const parent = new FlowModel({ ...modelOptions, uid: 'parent' });
model.setParent(parent);
expect(model.parent).toBe(parent);
expect(() => model.setParent(null as any)).toThrow('Parent must be an instance of FlowModel');
});
});
describe('subModels management', () => {
let parentModel: FlowModel;
beforeEach(() => {
parentModel = new FlowModel(modelOptions);
});
describe('setSubModel (object type)', () => {
test('should set single subModel with FlowModel instance', () => {
const childModel = new FlowModel({
uid: 'child-model-uid',
flowEngine,
stepParams: { childFlow: { childStep: { childParam: 'childValue' } } },
});
const result = parentModel.setSubModel('testChild', childModel);
expect(result.uid).toBe(childModel.uid);
expect(result.parent).toBe(parentModel);
expect((parentModel.subModels.testChild as FlowModel).uid).toBe(result.uid);
expect(result.uid).toBe('child-model-uid');
});
test('should replace existing subModel', () => {
const firstChild = new FlowModel({ uid: 'first-child', flowEngine });
const secondChild = new FlowModel({ uid: 'second-child', flowEngine });
parentModel.setSubModel('testChild', firstChild);
const result = parentModel.setSubModel('testChild', secondChild);
expect(result.uid).toBe(secondChild.uid);
expect((parentModel.subModels.testChild as FlowModel).uid).toBe(result.uid);
expect(result.uid).toBe('second-child');
});
test('should throw error when setting model with existing parent', () => {
const childModel = new FlowModel({ uid: 'child-with-parent', flowEngine });
const otherParent = new FlowModel({ uid: 'other-parent', flowEngine });
childModel.setParent(otherParent);
expect(() => {
parentModel.setSubModel('testChild', childModel);
}).toThrow('Sub model already has a parent.');
});
test('should emit onSubModelAdded event', () => {
const eventSpy = vi.fn();
parentModel.emitter.on('onSubModelAdded', eventSpy);
const childModel = new FlowModel({ uid: 'test-child', flowEngine });
const result = parentModel.setSubModel('testChild', childModel);
expect(eventSpy).toHaveBeenCalledWith(result);
});
test('should allow setSubModel via fork and bind to master', () => {
const childModel = new FlowModel({ uid: 'object-child-via-fork', flowEngine });
const fork = parentModel.createFork();
const result = (fork as any).setSubModel('testChildObject', childModel);
expect(result.parent).toBe(parentModel);
expect((parentModel.subModels as any)['testChildObject']).toBe(result);
});
test('should allow multiple setSubModel via fork with same instance without error', () => {
const childModel = new FlowModel({ uid: 'object-child-via-fork-2', flowEngine });
const fork = parentModel.createFork();
const first = (fork as any).setSubModel('testChildObject2', childModel);
const second = (fork as any).setSubModel('testChildObject2', childModel);
expect(first).toBe(second);
expect(second.parent).toBe(parentModel);
expect((parentModel.subModels as any)['testChildObject2']).toBe(second);
});
});
describe('addSubModel (array type)', () => {
test('should add subModel to array with FlowModel instance', () => {
const childModel = new FlowModel({
uid: 'child-model-uid',
flowEngine,
});
const result = parentModel.addSubModel('testChildren', childModel);
expect(result.uid).toBe(childModel.uid);
expect(result.parent).toBe(parentModel);
expect(Array.isArray(parentModel.subModels.testChildren)).toBe(true);
expect((parentModel.subModels.testChildren as FlowModel[]).some((model) => model.uid === result.uid)).toBe(
true,
);
expect(result.sortIndex).toBe(1);
});
test('should add multiple subModels with correct sortIndex', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine });
const child2 = new FlowModel({ uid: 'child2', flowEngine });
const child3 = new FlowModel({ uid: 'child3', flowEngine });
parentModel.addSubModel('testChildren', child1);
parentModel.addSubModel('testChildren', child2);
parentModel.addSubModel('testChildren', child3);
expect(child1.sortIndex).toBe(1);
expect(child2.sortIndex).toBe(2);
expect(child3.sortIndex).toBe(3);
expect(parentModel.subModels.testChildren).toHaveLength(3);
});
test('should maintain sortIndex when adding to existing array', () => {
const existingChild = new FlowModel({ uid: 'existing', flowEngine, sortIndex: 5 });
(parentModel.subModels as any).testChildren = [existingChild];
const newChild = new FlowModel({ uid: 'new-child', flowEngine });
parentModel.addSubModel('testChildren', newChild);
expect(newChild.sortIndex).toBe(6); // Should be max(5) + 1
});
test('should throw error when adding model with existing parent', () => {
const childModel = new FlowModel({ uid: 'child-with-parent', flowEngine });
const otherParent = new FlowModel({ uid: 'other-parent', flowEngine });
childModel.setParent(otherParent);
expect(() => {
parentModel.addSubModel('testChildren', childModel);
}).toThrow('Sub model already has a parent.');
});
test('should emit onSubModelAdded event', () => {
const eventSpy = vi.fn();
parentModel.emitter.on('onSubModelAdded', eventSpy);
const childModel = new FlowModel({ uid: 'test-child', flowEngine });
const result = parentModel.addSubModel('testChildren', childModel);
expect(eventSpy).toHaveBeenCalledWith(result);
});
test('should allow addSubModel via fork and bind to master', () => {
const childModel = new FlowModel({ uid: 'child-via-fork', flowEngine });
const fork = parentModel.createFork();
const result = (fork as any).addSubModel('testChildren', childModel);
expect(result.parent).toBe(parentModel);
expect(Array.isArray(parentModel.subModels.testChildren)).toBe(true);
expect((parentModel.subModels.testChildren as FlowModel[]).some((m) => m.uid === 'child-via-fork')).toBe(
true,
);
});
test('should allow multiple addSubModel via fork with same instance without error', () => {
const childModel = new FlowModel({ uid: 'child-via-fork-2', flowEngine });
const fork = parentModel.createFork();
const r1 = (fork as any).addSubModel('testChildren2', childModel);
const r2 = (fork as any).addSubModel('testChildren2', childModel);
expect(r1).toBe(r2);
expect(r1.parent).toBe(parentModel);
const arr = (parentModel.subModels as any)['testChildren2'];
expect(Array.isArray(arr)).toBe(true);
// allow duplicate binding without throwing
expect(arr.length).toBe(2);
expect(arr[0]).toBe(childModel);
expect(arr[1]).toBe(childModel);
});
});
describe('mapSubModels', () => {
test('should map over array subModels', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine });
const child2 = new FlowModel({ uid: 'child2', flowEngine });
parentModel.addSubModel('testChildren', child1);
parentModel.addSubModel('testChildren', child2);
const results = parentModel.mapSubModels('testChildren', (model, index) => ({
uid: model.uid,
index,
}));
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ uid: 'child1', index: 0 });
expect(results[1]).toEqual({ uid: 'child2', index: 1 });
});
test('should map over single subModel', () => {
const child = new FlowModel({ uid: 'single-child', flowEngine });
parentModel.setSubModel('testChild', child);
const results = parentModel.mapSubModels('testChild', (model, index) => ({
uid: model.uid,
index,
}));
expect(results).toHaveLength(1);
expect(results[0]).toEqual({ uid: 'single-child', index: 0 });
});
test('should return empty array for non-existent subModel', () => {
const results = parentModel.mapSubModels('nonExistent', (model) => model.uid);
expect(results).toEqual([]);
});
});
describe('findSubModel', () => {
test('should find subModel by condition in array', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine });
const child2 = new FlowModel({ uid: 'child2', flowEngine });
parentModel.addSubModel('testChildren', child1);
parentModel.addSubModel('testChildren', child2);
const found = parentModel.findSubModel('testChildren', (model) => model.uid === 'child2');
expect(found).toBeDefined();
});
test('should find single subModel by condition', () => {
const child = new FlowModel({ uid: 'target-child', flowEngine });
parentModel.setSubModel('testChild', child);
const found = parentModel.findSubModel('testChild', (model) => model.uid === 'target-child');
expect(found).toBeDefined();
});
test('should return null when no match found', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine });
parentModel.addSubModel('testChildren', child1);
const found = parentModel.findSubModel('testChildren', (model) => model.uid === 'nonexistent');
expect(found).toBeNull();
});
test('should return null for non-existent subModel key', () => {
const found = parentModel.findSubModel('nonExistent', () => true);
expect(found).toBeNull();
});
});
describe('applySubModelsAutoFlows', () => {
test('should apply auto flows to all array subModels', async () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine });
const child2 = new FlowModel({ uid: 'child2', flowEngine });
child1.applyAutoFlows = vi.fn().mockResolvedValue([]);
child2.applyAutoFlows = vi.fn().mockResolvedValue([]);
parentModel.addSubModel('children', child1);
parentModel.addSubModel('children', child2);
const runtimeData = { test: 'extra' };
await parentModel.applySubModelsAutoFlows('children', runtimeData);
expect(child1.applyAutoFlows).toHaveBeenCalledWith(runtimeData);
expect(child2.applyAutoFlows).toHaveBeenCalledWith(runtimeData);
});
test('should apply auto flows to single subModel', async () => {
const child = new FlowModel({ uid: 'child', flowEngine });
child.applyAutoFlows = vi.fn().mockResolvedValue([]);
parentModel.setSubModel('child', child);
const runtimeData = { test: 'extra' };
await parentModel.applySubModelsAutoFlows('child', runtimeData);
expect(child.applyAutoFlows).toHaveBeenCalledWith(runtimeData);
});
test('should handle empty subModels gracefully', async () => {
await expect(parentModel.applySubModelsAutoFlows('nonExistent')).resolves.not.toThrow();
});
});
describe('subModels serialization', () => {
test('should serialize subModels in model data', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine });
const child2 = new FlowModel({ uid: 'child2', flowEngine });
parentModel.setSubModel('singleChild', child1);
parentModel.addSubModel('multipleChildren', child2);
const serialized = parentModel.serialize();
expect(serialized.subModels).toBeDefined();
expect(serialized.subModels.singleChild).toBeDefined();
expect(serialized.subModels.multipleChildren).toBeDefined();
});
test('should handle empty subModels in serialization', () => {
const serialized = parentModel.serialize();
expect(serialized.subModels).toBeDefined();
expect(typeof serialized.subModels).toBe('object');
});
});
describe('subModels reactive behavior', () => {
test('should trigger reactive updates when subModels change', () => {
const child = new FlowModel({ uid: 'reactive-child', flowEngine });
let reactionTriggered = false;
// Mock a simple reaction to observe changes
const observer = () => {
reactionTriggered = true;
};
// Observe changes to subModels
parentModel.on('subModelChanged', observer);
// Add a subModel and verify props are reactive
parentModel.setSubModel('reactiveTest', child);
// Test that the subModel was added
expect(parentModel.subModels.reactiveTest).toBeDefined();
expect((parentModel.subModels.reactiveTest as FlowModel).uid).toBe('reactive-child');
// Test that props are observable
child.setProps({ reactiveTest: 'initialValue' });
expect(child.props.reactiveTest).toBe('initialValue');
// Change props and verify it's reactive
child.setProps({ reactiveTest: 'updatedValue' });
expect(child.props.reactiveTest).toBe('updatedValue');
});
test('should maintain reactive stepParams', () => {
const child = new FlowModel({ uid: 'step-params-child', flowEngine });
parentModel.setSubModel('stepParamsTest', child);
// Set initial step params
child.setStepParams({ testFlow: { testStep: { param1: 'initial' } } });
expect(child.stepParams.testFlow.testStep.param1).toBe('initial');
// Update step params and verify reactivity
child.setStepParams({ testFlow: { testStep: { param1: 'updated', param2: 'new' } } });
expect(child.stepParams.testFlow.testStep.param1).toBe('updated');
expect(child.stepParams.testFlow.testStep.param2).toBe('new');
});
});
describe('subModels edge cases', () => {
test('should handle null parent gracefully', () => {
const child = new FlowModel({ uid: 'orphan-child', flowEngine });
expect(() => {
parentModel.setSubModel('testChild', child);