UNPKG

@nocobase/flow-engine

Version:

A standalone flow engine for NocoBase, managing workflows, models, and actions.

1,379 lines (1,095 loc) 94.4 kB
/** * 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);