chore: unit tests for fork model

This commit is contained in:
gchust 2025-06-30 09:03:50 +08:00
parent b0ee88f8e1
commit 0ca5272a41

View File

@ -0,0 +1,906 @@
/**
* 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 { vi } from 'vitest';
import { ForkFlowModel } from '../forkFlowModel';
import { FlowModel } from '../flowModel';
import { FlowEngine } from '../../flowEngine';
import type { IModelComponentProps, FlowModelOptions } from '../../types';
import { APIClient } from '@nocobase/sdk';
// Mock dependencies
vi.mock('@formily/reactive', async () => {
const actual = await vi.importActual('@formily/reactive');
return {
...actual,
action: (fn: any) => fn,
define: vi.fn(),
observable: (obj: any) => obj,
};
});
vi.mock('uid/secure', () => ({
uid: vi.fn(() => 'mock-uid-' + Math.random().toString(36).substring(2, 11)),
}));
// Helper functions
const createMockFlowEngine = (): FlowEngine => {
const mockEngine = {
getModel: vi.fn(),
createModel: vi.fn(),
getAction: vi.fn(),
getContext: vi.fn(() => ({ app: {} as any, api: {} as APIClient, flowEngine: mockEngine as FlowEngine })),
translate: vi.fn((key: string) => key),
reactView: null as any,
} as Partial<FlowEngine>;
return mockEngine as FlowEngine;
};
const createMockFlowModel = (overrides: Partial<FlowModelOptions> = {}): FlowModel => {
const flowEngine = createMockFlowEngine();
const options = {
uid: 'test-master-uid',
flowEngine,
props: { masterProp: 'masterValue' },
stepParams: { testFlow: { step1: { param1: 'value1' } } },
sortIndex: 0,
subModels: {},
async: false,
...overrides,
};
const model = new FlowModel(options);
// Mock essential methods
model.getProps = vi.fn(() => options.props || {});
model.render = vi.fn(() => ({ type: 'div', props: model.props }));
model.getSharedContext = vi.fn(() => ({}));
// Add mock properties
(model as any).forks = new Set();
(model as any).forkCache = new Map();
(model as any).flowEngine = flowEngine;
(model as any)._sharedContext = {};
(model as any).parent = null;
// Mock the async getter
Object.defineProperty(model, 'async', {
get: () => options.async || false,
configurable: true,
});
return model;
};
// Test setup
let mockMaster: FlowModel;
let initialProps: IModelComponentProps;
beforeEach(() => {
mockMaster = createMockFlowModel();
initialProps = { forkProp: 'forkValue' };
vi.clearAllMocks();
});
describe('ForkFlowModel', () => {
// ==================== CONSTRUCTOR & INITIALIZATION ====================
describe('Constructor & Initialization', () => {
test('should create fork with basic parameters', () => {
const fork = new ForkFlowModel(mockMaster, initialProps, 1);
expect(fork.uid).toBe(mockMaster.uid);
expect(fork.forkId).toBe(1);
expect(fork.localProps).toEqual(initialProps);
expect(fork.isFork).toBe(true);
expect((fork as any).master).toBe(mockMaster);
expect((fork as any).disposed).toBe(false);
});
test('should create fork with default parameters', () => {
const fork = new ForkFlowModel(mockMaster);
expect(fork.uid).toBe(mockMaster.uid);
expect(fork.forkId).toBe(0);
expect(fork.localProps).toEqual({});
expect(fork.isFork).toBe(true);
});
test('should return Proxy object', () => {
const fork = new ForkFlowModel(mockMaster, initialProps);
// Verify it's a Proxy by checking transparent property access
expect(typeof fork).toBe('object');
expect(fork.constructor).toBe(mockMaster.constructor);
});
});
// ==================== PROXY GET MECHANISM ====================
describe('Proxy Get Mechanism', () => {
let fork: ForkFlowModel;
beforeEach(() => {
fork = new ForkFlowModel(mockMaster, initialProps, 1);
});
test('should return disposed status correctly', () => {
expect(fork['disposed']).toBe(false);
fork.dispose();
expect(fork['disposed']).toBe(true);
});
test('should return master constructor for constructor property', () => {
expect(fork.constructor).toBe(mockMaster.constructor);
expect(fork.constructor).toBe(FlowModel);
});
test('should merge props from master and local', () => {
const masterProps = { masterProp: 'masterValue', shared: 'master' };
const localProps = { localProp: 'localValue', shared: 'local' };
mockMaster.getProps = vi.fn(() => masterProps);
fork.localProps = localProps;
const mergedProps = fork.props;
expect(mergedProps).toEqual({
masterProp: 'masterValue',
localProp: 'localValue',
shared: 'local', // Local should override master
});
});
test('should return fork own properties first', () => {
expect(fork.uid).toBe(mockMaster.uid);
expect(fork.forkId).toBe(1);
expect(fork.isFork).toBe(true);
expect(fork.localProps).toEqual(initialProps);
});
test('should access local properties storage', () => {
// Set a local property directly
(fork as any).localCustomProp = 'localValue';
expect((fork as any).localCustomProp).toBe('localValue');
});
test('should fallback to master properties', () => {
// Access master property that doesn\'t exist on fork
const masterStepParams = mockMaster.stepParams;
expect((fork as any).stepParams).toBe(masterStepParams);
});
test('should bind functions to fork instance', () => {
const testMethod = vi.fn(function (this: any) {
return this.uid;
});
(mockMaster as any).testMethod = testMethod;
const result = (fork as any).testMethod();
expect(testMethod).toHaveBeenCalled();
expect(result).toBe(fork.uid);
});
test('should preserve master constructor in function context', () => {
const testMethod = vi.fn(function (this: any) {
return this.constructor;
});
(mockMaster as any).testMethod = testMethod;
const result = (fork as any).testMethod();
expect(result).toBe(mockMaster.constructor);
});
test('should pass arguments to bound functions', () => {
const testMethod = vi.fn((arg1: string, arg2: number) => `${arg1}-${arg2}`);
(mockMaster as any).testMethod = testMethod;
const result = (fork as any).testMethod('test', 123);
expect(testMethod).toHaveBeenCalledWith('test', 123);
expect(result).toBe('test-123');
});
test('should handle non-function master properties', () => {
(mockMaster as any).masterData = { key: 'value' };
expect((fork as any).masterData).toEqual({ key: 'value' });
});
test('should handle undefined master properties', () => {
expect((fork as any).nonExistentProperty).toBeUndefined();
});
test('should create correct context object for functions', () => {
const contextChecker = vi.fn(function (this: any) {
return {
hasConstructor: 'constructor' in this,
constructorValue: this.constructor,
isConfigurable: Object.getOwnPropertyDescriptor(this, 'constructor')?.configurable,
isEnumerable: Object.getOwnPropertyDescriptor(this, 'constructor')?.enumerable,
isWritable: Object.getOwnPropertyDescriptor(this, 'constructor')?.writable,
};
});
(mockMaster as any).contextChecker = contextChecker;
const result = (fork as any).contextChecker();
expect(result.hasConstructor).toBe(true);
expect(result.constructorValue).toBe(mockMaster.constructor);
expect(result.isConfigurable).toBe(true);
expect(result.isEnumerable).toBe(false);
expect(result.isWritable).toBe(false);
});
});
// ==================== PROXY SET MECHANISM ====================
describe('Proxy Set Mechanism', () => {
let fork: ForkFlowModel;
beforeEach(() => {
fork = new ForkFlowModel(mockMaster, initialProps);
});
test('should ignore setting props property', () => {
const originalProps = fork.props;
(fork as any).props = { newProp: 'newValue' };
// Props should remain unchanged since set returns true but doesn't actually set
expect(fork.props).toEqual(originalProps);
});
test('should set fork own properties directly', () => {
fork.localProps = { newLocal: 'value' };
expect(fork.localProps).toEqual({ newLocal: 'value' });
});
test('should sync shared properties to master', () => {
const newStepParams = { newFlow: { newStep: { param: 'value' } } };
(fork as any).stepParams = newStepParams;
expect((mockMaster as any).stepParams).toBe(newStepParams);
});
test('should store non-shared properties locally', () => {
const customValue = { data: 'localData' };
(fork as any).customProperty = customValue;
expect((fork as any).customProperty).toBe(customValue);
expect((mockMaster as any).customProperty).toBeUndefined();
});
test('should call master setter for shared properties', () => {
const setterSpy = vi.fn();
// Mock a setter on master
Object.defineProperty(mockMaster, 'sortIndex', {
get: () => 0,
set: setterSpy,
configurable: true,
});
(fork as any).sortIndex = 5;
expect(setterSpy).toHaveBeenCalledWith(5);
});
test('should handle shared property without setter', () => {
// stepParams should be a shared property without custom setter
const newParams = { flow: { step: { param: 'test' } } };
(fork as any).stepParams = newParams;
expect((mockMaster as any).stepParams).toBe(newParams);
});
test('should identify shared properties correctly', () => {
const originalSharedProps = ForkFlowModel.getSharedProperties();
expect(originalSharedProps).toContain('stepParams');
expect(originalSharedProps).toContain('sortIndex');
});
test('should handle property descriptor lookup', () => {
// Create a property with descriptor on master
Object.defineProperty(mockMaster, 'testDescriptor', {
get: () => 'test',
set: vi.fn(),
configurable: true,
});
const fork = new ForkFlowModel(mockMaster, initialProps);
// Access the property descriptor indirectly through shared property setting
// ForkFlowModel.setSharedProperties(['testDescriptor']);
(fork as any).testDescriptor = 'newValue';
// Verify the setter was called (indirectly validates descriptor lookup)
const descriptor = Object.getOwnPropertyDescriptor(mockMaster, 'testDescriptor');
expect(descriptor?.set).not.toHaveBeenCalled();
});
test('should handle non-existent property descriptors', () => {
ForkFlowModel.setSharedProperties(['nonExistentProp']);
// Should not throw when setting property without descriptor
expect(() => {
(fork as any).nonExistentProp = 'value';
}).not.toThrow();
expect((mockMaster as any).nonExistentProp).toBe('value');
});
});
// ==================== SHARED PROPERTIES MANAGEMENT ====================
describe('Shared Properties Management', () => {
test('should set shared properties configuration', () => {
const newSharedProps = ['customProp1', 'customProp2'];
ForkFlowModel.setSharedProperties(newSharedProps);
expect(ForkFlowModel.getSharedProperties()).toEqual(newSharedProps);
});
test('should get current shared properties', () => {
const currentProps = ForkFlowModel.getSharedProperties();
expect(Array.isArray(currentProps)).toBe(true);
expect(currentProps.length).toBeGreaterThan(0);
});
test('should have default shared properties', () => {
// Reset to defaults
ForkFlowModel.setSharedProperties(['stepParams', 'sortIndex']);
const defaultProps = ForkFlowModel.getSharedProperties();
expect(defaultProps).toContain('stepParams');
expect(defaultProps).toContain('sortIndex');
});
test('should identify shared property correctly', () => {
ForkFlowModel.setSharedProperties(['testProp']);
const fork = new ForkFlowModel(mockMaster, initialProps);
// Use private method indirectly through property setting behavior
(fork as any).testProp = 'value';
expect((mockMaster as any).testProp).toBe('value');
});
test('should identify non-shared property correctly', () => {
ForkFlowModel.setSharedProperties(['onlyThisProp']);
const fork = new ForkFlowModel(mockMaster, initialProps);
(fork as any).nonSharedProp = 'value';
expect((fork as any).nonSharedProp).toBe('value');
expect((mockMaster as any).nonSharedProp).toBeUndefined();
});
});
// ==================== PROPS MANAGEMENT ====================
describe('Props Management', () => {
let fork: ForkFlowModel;
beforeEach(() => {
fork = new ForkFlowModel(mockMaster, { initial: 'value' });
});
test('should set props with string key-value', () => {
fork.setProps('newKey', 'newValue');
expect(fork.localProps.newKey).toBe('newValue');
expect(fork.localProps.initial).toBe('value'); // Should preserve existing
});
test('should set props with object', () => {
const newProps = { prop1: 'value1', prop2: 'value2' };
fork.setProps(newProps);
expect(fork.localProps).toEqual({
initial: 'value',
prop1: 'value1',
prop2: 'value2',
});
});
test('should merge props correctly', () => {
fork.setProps({ existing: 'updated', new: 'added' });
expect(fork.localProps).toEqual({
initial: 'value',
existing: 'updated',
new: 'added',
});
});
test('should get merged props from master and local', () => {
const masterProps = { master: 'value', shared: 'master' };
const localProps = { local: 'value', shared: 'local' };
mockMaster.getProps = vi.fn(() => masterProps);
fork.localProps = localProps;
const result = fork.getProps();
expect(result).toEqual({
master: 'value',
local: 'value',
shared: 'local', // Local should override
});
});
test('should maintain local props independence', () => {
const originalProps = { ...fork.localProps };
fork.setProps('newKey', 'newValue');
expect(fork.localProps.newKey).toBe('newValue');
expect(originalProps).not.toHaveProperty('newKey');
});
test('should ignore setProps when disposed', () => {
fork.dispose();
const originalProps = { ...fork.localProps };
fork.setProps('shouldIgnore', 'value');
expect(fork.localProps).toEqual(originalProps);
});
test('should handle props priority in getter', () => {
mockMaster.getProps = vi.fn(() => ({
masterOnly: 'master',
conflict: 'master',
}));
fork.localProps = {
localOnly: 'local',
conflict: 'local',
};
const merged = fork.props;
expect(merged.masterOnly).toBe('master');
expect(merged.localOnly).toBe('local');
expect(merged.conflict).toBe('local'); // Local wins
});
test('should handle nested object props', () => {
const nestedProps = {
user: { name: 'John', settings: { theme: 'dark' } },
config: { debug: true },
};
fork.setProps(nestedProps);
expect(fork.localProps.user).toEqual(nestedProps.user);
expect(fork.localProps.config).toEqual(nestedProps.config);
});
});
// ==================== CONTEXT AND SHARED STATE ====================
describe('Context and Shared State', () => {
let fork: ForkFlowModel;
beforeEach(() => {
fork = new ForkFlowModel(mockMaster, initialProps);
});
test('should set shared context', () => {
const contextData = { key1: 'value1', key2: 'value2' };
fork.setSharedContext(contextData);
expect((fork as any)._sharedContext).toEqual(contextData);
});
test('should merge shared context', () => {
fork.setSharedContext({ initial: 'value' });
fork.setSharedContext({ additional: 'data', initial: 'updated' });
expect((fork as any)._sharedContext).toEqual({
initial: 'updated',
additional: 'data',
});
});
test('should get shared context in async mode', () => {
// Mock async property on the fork
Object.defineProperty(fork, 'async', {
get: () => true,
configurable: true,
});
fork.setSharedContext({ async: 'context' });
const context = fork.getSharedContext();
expect(context).toEqual({ async: 'context' });
});
test('should get ctx with globals and shared', () => {
const globalContext = { app: {}, api: {} };
const sharedContext = { shared: 'data' };
(mockMaster as any).flowEngine.getContext = vi.fn(() => globalContext);
fork.setSharedContext(sharedContext);
const ctx = fork.ctx;
expect(ctx.globals).toBe(globalContext);
expect(ctx.shared).toEqual(sharedContext);
});
test('should handle empty shared context', () => {
const context = fork.getSharedContext();
expect(context).toEqual({});
});
});
// ==================== RENDER MECHANISM ====================
describe('Render Mechanism', () => {
let fork: ForkFlowModel;
beforeEach(() => {
fork = new ForkFlowModel(mockMaster, { localStyle: 'local' });
});
test('should render with merged props', () => {
const masterProps = { masterStyle: 'master', shared: 'master' };
const expectedMerged = {
masterStyle: 'master',
localStyle: 'local',
shared: 'local',
};
mockMaster.getProps = vi.fn(() => masterProps);
fork.localProps = { localStyle: 'local', shared: 'local' };
mockMaster.render = vi.fn(function (this: any) {
return { type: 'div', props: this.props };
});
const result = fork.render();
expect(mockMaster.render).toHaveBeenCalled();
expect(result.props).toEqual(expectedMerged);
});
test('should call master render with fork as this', () => {
let renderThis: any;
mockMaster.render = vi.fn(function (this: any) {
renderThis = this;
return { type: 'span', props: this.props };
});
fork.render();
expect(renderThis).toBe(fork);
expect(renderThis.isFork).toBe(true);
});
// TODO: This test case indicates a bug? even it is passing... we should not restore!
// test('should restore original props after render', () => {
// const originalProps = fork.props;
// mockMaster.render = vi.fn(function(this: any) {
// // Modify props during render
// this.props = { modified: 'during-render' };
// return { type: 'div', props: this.props };
// });
// fork.render();
// // Props should be restored
// expect(fork.props).toEqual(originalProps);
// });
test('should return null when disposed', () => {
fork.dispose();
const result = fork.render();
expect(result).toBeNull();
expect(mockMaster.render).not.toHaveBeenCalled();
});
test('should handle render exceptions gracefully', () => {
mockMaster.render = vi.fn(() => {
throw new Error('Render error');
});
expect(() => fork.render()).toThrow('Render error');
});
});
// ==================== LIFECYCLE MANAGEMENT ====================
describe('Lifecycle Management', () => {
let fork: ForkFlowModel;
beforeEach(() => {
mockMaster = createMockFlowModel();
(mockMaster as any).forks = new Set();
(mockMaster as any).forkCache = new Map();
fork = new ForkFlowModel(mockMaster, initialProps);
(mockMaster as any).forks.add(fork);
});
test('should dispose and change status', () => {
expect(fork['disposed']).toBe(false);
fork.dispose();
expect(fork['disposed']).toBe(true);
});
test('should remove from master forks collection', () => {
expect((mockMaster as any).forks.has(fork)).toBe(true);
fork.dispose();
expect((mockMaster as any).forks.has(fork)).toBe(false);
});
test('should remove from master fork cache', () => {
const cacheKey = 'testKey';
(mockMaster as any).forkCache.set(cacheKey, fork);
fork.dispose();
expect((mockMaster as any).forkCache.has(cacheKey)).toBe(false);
});
test('should clear master reference', () => {
fork.dispose();
expect((fork as any).master).toBeNull();
});
test('should handle dispose when already disposed', () => {
fork.dispose();
// Should not throw on second dispose
expect(() => fork.dispose()).not.toThrow();
expect(fork['disposed']).toBe(true);
});
test('should find and remove correct fork from cache', () => {
const fork1 = new ForkFlowModel(mockMaster, {}, 1);
const fork2 = new ForkFlowModel(mockMaster, {}, 2);
(mockMaster as any).forkCache.set('key1', fork1);
(mockMaster as any).forkCache.set('key2', fork2);
fork1.dispose();
expect((mockMaster as any).forkCache.has('key1')).toBe(false);
expect((mockMaster as any).forkCache.has('key2')).toBe(true);
});
});
// ==================== FUNCTION BINDING AND CONTEXT ====================
describe('Function Binding and Context', () => {
let fork: ForkFlowModel;
beforeEach(() => {
fork = new ForkFlowModel(mockMaster, initialProps);
});
test('should bind master methods to fork instance', () => {
const boundMethod = vi.fn(function (this: any) {
return {
uid: this.uid,
isFork: this.isFork,
forkId: this.forkId,
};
});
(mockMaster as any).boundMethod = boundMethod;
const result = (fork as any).boundMethod();
expect(result.uid).toBe(fork.uid);
expect(result.isFork).toBe(true);
expect(result.forkId).toBe(fork.forkId);
});
test('should preserve constructor in bound method context', () => {
const constructorChecker = vi.fn(function (this: any) {
return this.constructor === mockMaster.constructor;
});
(mockMaster as any).constructorChecker = constructorChecker;
const result = (fork as any).constructorChecker();
expect(result).toBe(true);
});
test('should handle async methods correctly', async () => {
const asyncMethod = vi.fn(async function (this: any) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
uid: this.uid,
constructor: this.constructor,
});
}, 0);
});
});
(mockMaster as any).asyncMethod = asyncMethod;
const result = await (fork as any).asyncMethod();
expect(result.uid).toBe(fork.uid);
expect(result.constructor).toBe(mockMaster.constructor);
});
test('should pass method arguments correctly', () => {
const methodWithArgs = vi.fn(function (arg1: string, arg2: number, arg3: object) {
return { arg1, arg2, arg3 };
});
(mockMaster as any).methodWithArgs = methodWithArgs;
const testObj = { test: 'object' };
const result = (fork as any).methodWithArgs('test', 123, testObj);
expect(methodWithArgs).toHaveBeenCalledWith('test', 123, testObj);
expect(result).toEqual({ arg1: 'test', arg2: 123, arg3: testObj });
});
test('should handle method return values correctly', () => {
const methodWithReturn = vi.fn(() => ({ success: true, data: 'result' }));
(mockMaster as any).methodWithReturn = methodWithReturn;
const result = (fork as any).methodWithReturn();
expect(result).toEqual({ success: true, data: 'result' });
});
test('should create correct context object properties', () => {
const contextInspector = vi.fn(function (this: any) {
const descriptor = Object.getOwnPropertyDescriptor(this, 'constructor');
return {
hasOwnConstructor: Object.prototype.hasOwnProperty.call(this, 'constructor'),
constructorValue: this.constructor,
descriptorExists: !!descriptor,
configurable: descriptor?.configurable,
enumerable: descriptor?.enumerable,
writable: descriptor?.writable,
};
});
(mockMaster as any).contextInspector = contextInspector;
const result = (fork as any).contextInspector();
expect(result.hasOwnConstructor).toBe(true);
expect(result.constructorValue).toBe(mockMaster.constructor);
expect(result.descriptorExists).toBe(true);
expect(result.configurable).toBe(true);
expect(result.enumerable).toBe(false);
expect(result.writable).toBe(false);
});
test('should handle closure constructor capture', () => {
// Create a method that captures constructor in closure
let capturedConstructor: any;
const closureMethod = function (this: any) {
capturedConstructor = this.constructor;
return this.constructor;
};
(mockMaster as any).closureMethod = closureMethod;
const result = (fork as any).closureMethod();
expect(capturedConstructor).toBe(mockMaster.constructor);
expect(result).toBe(mockMaster.constructor);
});
test('should maintain correct this binding in nested calls', () => {
const nestedMethod = vi.fn(function (this: any) {
return this.uid;
});
const callerMethod = vi.fn(function (this: any) {
return {
directUid: this.uid,
nestedUid: nestedMethod.call(this),
};
});
(mockMaster as any).nestedMethod = nestedMethod;
(mockMaster as any).callerMethod = callerMethod;
const result = (fork as any).callerMethod();
expect(result.directUid).toBe(fork.uid);
expect(result.nestedUid).toBe(fork.uid);
});
});
// ==================== EDGE CASES AND ERROR HANDLING ====================
describe('Edge Cases and Error Handling', () => {
let fork: ForkFlowModel;
beforeEach(() => {
fork = new ForkFlowModel(mockMaster, initialProps);
});
test('should handle accessing non-existent properties', () => {
const value = (fork as any).totallyNonExistentProperty;
expect(value).toBeUndefined();
});
test('should handle setting props property directly', () => {
const originalProps = fork.props;
// This should be ignored by the proxy
(fork as any).props = { ignored: 'value' };
// Props should be accessed through the getter mechanism
expect(fork.props).toEqual(originalProps);
});
test('should handle master being null during property access', () => {
// Force master to null (simulating a disposed state)
(fork as any).master = null;
expect(() => {
const value = (fork as any).someProperty;
}).toThrow('Cannot read properties of null');
});
test('should handle setProps with null/undefined values', () => {
fork.setProps('nullProp', null);
fork.setProps('undefinedProp', undefined);
expect(fork.localProps.nullProp).toBeNull();
expect(fork.localProps.undefinedProp).toBeUndefined();
});
test('should handle complex nested property access patterns', () => {
// Set up complex nested structure
(mockMaster as any).nested = {
deep: {
property: {
value: 'deep value',
},
},
};
expect((fork as any).nested.deep.property.value).toBe('deep value');
});
});
});