mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-01 18:52:20 +08:00
test: add unit tests for utils
This commit is contained in:
parent
a37828c3cd
commit
8c37c88b53
@ -1259,7 +1259,9 @@ describe('FlowModel', () => {
|
||||
group: 'test',
|
||||
});
|
||||
|
||||
const mockTranslate = vi.fn().mockReturnValue('Translated Title');
|
||||
const mockTranslate = vi.fn((v) => {
|
||||
if (v) return 'Translated Title';
|
||||
});
|
||||
const mockFlowEngine = {
|
||||
...flowEngine,
|
||||
translate: mockTranslate,
|
||||
@ -1271,7 +1273,7 @@ describe('FlowModel', () => {
|
||||
|
||||
const title = modelWithTranslate.title;
|
||||
|
||||
expect(mockTranslate).toHaveBeenCalledWith('model.title.key');
|
||||
expect(mockTranslate).toHaveBeenLastCalledWith('model.title.key');
|
||||
expect(title).toBe('Translated Title');
|
||||
});
|
||||
|
||||
@ -1443,7 +1445,9 @@ describe('FlowModel', () => {
|
||||
|
||||
describe('title with translation', () => {
|
||||
test('should call translate method for meta title', () => {
|
||||
const mockTranslate = vi.fn().mockReturnValue('Translated Meta Title');
|
||||
const mockTranslate = vi.fn((v) => {
|
||||
if (v) return 'Translated Meta Title';
|
||||
});
|
||||
|
||||
TestFlowModel.define({
|
||||
title: 'meta.title.key',
|
||||
@ -1461,7 +1465,7 @@ describe('FlowModel', () => {
|
||||
|
||||
const title = modelWithTranslate.title;
|
||||
|
||||
expect(mockTranslate).toHaveBeenCalledWith('meta.title.key');
|
||||
expect(mockTranslate).toHaveBeenLastCalledWith('meta.title.key');
|
||||
expect(title).toBe('Translated Meta Title');
|
||||
});
|
||||
});
|
||||
|
983
packages/core/flow-engine/src/models/__tests__/utils.test.ts
Normal file
983
packages/core/flow-engine/src/models/__tests__/utils.test.ts
Normal file
@ -0,0 +1,983 @@
|
||||
/**
|
||||
* 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 { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
getT,
|
||||
generateUid,
|
||||
mergeFlowDefinitions,
|
||||
isInheritedFrom,
|
||||
resolveDefaultParams,
|
||||
resolveUiSchema,
|
||||
FlowExitException,
|
||||
defineAction,
|
||||
compileUiSchema,
|
||||
FLOW_ENGINE_NAMESPACE,
|
||||
} from '../../utils';
|
||||
import { FlowModel } from '../flowModel';
|
||||
import { FlowEngine } from '../../flowEngine';
|
||||
import type {
|
||||
FlowDefinition,
|
||||
ParamsContext,
|
||||
ActionDefinition,
|
||||
DeepPartial,
|
||||
ModelConstructor,
|
||||
StepParams,
|
||||
} from '../../types';
|
||||
import type { ISchema } from '@formily/json-schema';
|
||||
import type { APIClient } from '@nocobase/sdk';
|
||||
|
||||
// Helper functions
|
||||
const createMockFlowEngine = (): FlowEngine => {
|
||||
const mockEngine = {
|
||||
translate: vi.fn((key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnOriginal) return key;
|
||||
return `translated_${key}`;
|
||||
}),
|
||||
getContext: vi.fn(() => ({ app: {}, api: {} as APIClient, flowEngine: mockEngine as FlowEngine })),
|
||||
createModel: vi.fn(),
|
||||
getModel: vi.fn(),
|
||||
applyFlowCache: new Map(),
|
||||
} as Partial<FlowEngine>;
|
||||
|
||||
return mockEngine as FlowEngine;
|
||||
};
|
||||
|
||||
interface MockFlowModelOptions {
|
||||
uid?: string;
|
||||
flowEngine?: FlowEngine;
|
||||
stepParams?: StepParams;
|
||||
sortIndex?: number;
|
||||
subModels?: Record<string, FlowModel | FlowModel[]>;
|
||||
}
|
||||
|
||||
const createMockFlowModel = (overrides: MockFlowModelOptions = {}): FlowModel => {
|
||||
const flowEngine = createMockFlowEngine();
|
||||
const options = {
|
||||
uid: 'test-model-uid',
|
||||
flowEngine,
|
||||
stepParams: {},
|
||||
sortIndex: 0,
|
||||
subModels: {},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const model = new FlowModel(options);
|
||||
// Ensure the flowEngine is properly set
|
||||
Object.defineProperty(model, 'flowEngine', {
|
||||
value: flowEngine,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
return model;
|
||||
};
|
||||
|
||||
const createBasicFlowDefinition = (overrides: Partial<FlowDefinition> = {}): FlowDefinition => ({
|
||||
key: 'testFlow',
|
||||
steps: {
|
||||
step1: {
|
||||
handler: vi.fn().mockResolvedValue('step1-result'),
|
||||
},
|
||||
step2: {
|
||||
handler: vi.fn().mockResolvedValue('step2-result'),
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPatchFlowDefinition = (
|
||||
overrides: Partial<DeepPartial<FlowDefinition>> = {},
|
||||
): DeepPartial<FlowDefinition> => ({
|
||||
title: 'Patched Flow',
|
||||
steps: {
|
||||
step1: {
|
||||
handler: vi.fn().mockResolvedValue('patched-step1-result'),
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Test setup
|
||||
let mockModel: FlowModel;
|
||||
let mockFlowEngine: FlowEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFlowEngine = createMockFlowEngine();
|
||||
mockModel = createMockFlowModel();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Utils', () => {
|
||||
// ==================== getT() FUNCTION ====================
|
||||
describe('getT()', () => {
|
||||
describe('basic translation functionality', () => {
|
||||
test('should return translation function when flowEngine.translate exists', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
expect(typeof translateFn).toBe('function');
|
||||
});
|
||||
|
||||
test('should call flowEngine.translate with correct parameters', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
translateFn('test.key', { custom: 'option' });
|
||||
|
||||
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('test.key', {
|
||||
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
|
||||
nsMode: 'fallback',
|
||||
custom: 'option',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return translated result from flowEngine', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
const result = translateFn('hello.world');
|
||||
|
||||
expect(result).toBe('translated_hello.world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('namespace handling', () => {
|
||||
test('should add flow-engine namespace by default', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
translateFn('key');
|
||||
|
||||
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
|
||||
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
|
||||
nsMode: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
test('should merge with existing options', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
translateFn('key', { ns: ['custom'], extraOption: 'value' });
|
||||
|
||||
// The implementation spreads options after defaults, so options override defaults
|
||||
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
|
||||
ns: ['custom'], // options.ns overrides default ns
|
||||
nsMode: 'fallback',
|
||||
extraOption: 'value',
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow nsMode override', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
translateFn('key', { nsMode: 'strict' });
|
||||
|
||||
// The implementation spreads options after defaults, so options override defaults
|
||||
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
|
||||
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
|
||||
nsMode: 'strict', // options.nsMode overrides default nsMode
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback mechanism', () => {
|
||||
test('should return fallback function when no flowEngine', () => {
|
||||
const modelWithoutEngine = { flowEngine: null } as unknown as FlowModel;
|
||||
|
||||
const translateFn = getT(modelWithoutEngine);
|
||||
|
||||
expect(typeof translateFn).toBe('function');
|
||||
expect(translateFn('test.key')).toBe('test.key');
|
||||
});
|
||||
|
||||
test('should return fallback function when no translate method', () => {
|
||||
const modelWithoutTranslate = {
|
||||
flowEngine: { translate: null },
|
||||
} as unknown as FlowModel;
|
||||
|
||||
const translateFn = getT(modelWithoutTranslate);
|
||||
|
||||
expect(translateFn('test.key')).toBe('test.key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
test('should handle translate method throwing errors', () => {
|
||||
mockModel.flowEngine.translate = vi.fn(() => {
|
||||
throw new Error('Translation error');
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
const translateFn = getT(mockModel);
|
||||
translateFn('test.key');
|
||||
}).toThrow('Translation error');
|
||||
});
|
||||
|
||||
test('should handle null options parameter', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
translateFn('key', null);
|
||||
|
||||
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
|
||||
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
|
||||
nsMode: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle undefined options parameter', () => {
|
||||
const translateFn = getT(mockModel);
|
||||
|
||||
translateFn('key');
|
||||
|
||||
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
|
||||
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
|
||||
nsMode: 'fallback',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== generateUid() FUNCTION ====================
|
||||
describe('generateUid()', () => {
|
||||
describe('basic generation functionality', () => {
|
||||
test('should generate a string', () => {
|
||||
const uid = generateUid();
|
||||
|
||||
expect(typeof uid).toBe('string');
|
||||
expect(uid).toBeDefined();
|
||||
expect(uid.length).toBeGreaterThan(10); // Should be reasonably long
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueness validation', () => {
|
||||
test('should generate different UIDs on consecutive calls', () => {
|
||||
const uid1 = generateUid();
|
||||
const uid2 = generateUid();
|
||||
|
||||
expect(uid1).not.toBe(uid2);
|
||||
});
|
||||
|
||||
test('should generate unique UIDs in bulk', () => {
|
||||
const uids = Array.from({ length: 100 }, () => generateUid());
|
||||
const uniqueUids = new Set(uids);
|
||||
|
||||
expect(uniqueUids.size).toBe(100); // All should be unique
|
||||
});
|
||||
|
||||
test('should maintain uniqueness across multiple executions', () => {
|
||||
const batch1 = Array.from({ length: 10 }, () => generateUid());
|
||||
const batch2 = Array.from({ length: 10 }, () => generateUid());
|
||||
|
||||
const allUids = [...batch1, ...batch2];
|
||||
const uniqueUids = new Set(allUids);
|
||||
|
||||
expect(uniqueUids.size).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('format validation', () => {
|
||||
test('should contain only alphanumeric characters', () => {
|
||||
const uid = generateUid();
|
||||
|
||||
expect(/^[a-z0-9]+$/.test(uid)).toBe(true);
|
||||
});
|
||||
|
||||
test('should have consistent length range', () => {
|
||||
const uids = Array.from({ length: 50 }, () => generateUid());
|
||||
|
||||
uids.forEach((uid) => {
|
||||
expect(uid.length).toBeGreaterThan(15);
|
||||
expect(uid.length).toBeLessThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
test('should not contain special characters', () => {
|
||||
const uid = generateUid();
|
||||
|
||||
expect(uid).not.toMatch(/[^a-z0-9]/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== mergeFlowDefinitions() FUNCTION ====================
|
||||
describe('mergeFlowDefinitions()', () => {
|
||||
let originalFlow: FlowDefinition;
|
||||
let patchFlow: DeepPartial<FlowDefinition>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFlow = createBasicFlowDefinition({
|
||||
title: 'Original Flow',
|
||||
on: { eventName: 'originalEvent' },
|
||||
});
|
||||
patchFlow = createPatchFlowDefinition();
|
||||
});
|
||||
|
||||
describe('basic merging functionality', () => {
|
||||
test('should merge flow definitions correctly', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, patchFlow);
|
||||
|
||||
expect(merged.title).toBe('Patched Flow');
|
||||
expect(merged.key).toBe(originalFlow.key);
|
||||
expect(merged.steps).toBeDefined();
|
||||
});
|
||||
|
||||
test('should preserve original flow when patch is empty', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, {});
|
||||
|
||||
expect(merged.title).toBe(originalFlow.title);
|
||||
expect(merged.key).toBe(originalFlow.key);
|
||||
expect(merged.steps).toEqual(originalFlow.steps);
|
||||
});
|
||||
|
||||
test('should handle undefined patch properties', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, {
|
||||
title: undefined,
|
||||
steps: undefined,
|
||||
});
|
||||
|
||||
expect(merged.title).toBe(originalFlow.title);
|
||||
expect(merged.steps).toEqual(originalFlow.steps);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property override', () => {
|
||||
test('should override title when provided in patch', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, {
|
||||
title: 'Overridden Title',
|
||||
});
|
||||
|
||||
expect(merged.title).toBe('Overridden Title');
|
||||
});
|
||||
|
||||
test('should override event configuration when provided', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, {
|
||||
on: { eventName: 'newEvent' },
|
||||
});
|
||||
|
||||
expect(merged.on).toEqual({ eventName: 'newEvent' });
|
||||
});
|
||||
|
||||
test('should preserve original properties when not in patch', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, {
|
||||
title: 'New Title',
|
||||
});
|
||||
|
||||
expect(merged.key).toBe(originalFlow.key);
|
||||
expect(merged.on).toEqual(originalFlow.on);
|
||||
});
|
||||
|
||||
test('should handle null values in patch', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, {
|
||||
title: null,
|
||||
});
|
||||
|
||||
expect(merged.title).toBe(originalFlow.title);
|
||||
});
|
||||
});
|
||||
|
||||
describe('steps merging', () => {
|
||||
test('should merge step definitions correctly', () => {
|
||||
const patchWithSteps = {
|
||||
steps: {
|
||||
step1: { newProperty: 'value', use: 'confirm' },
|
||||
step3: { handler: vi.fn() },
|
||||
},
|
||||
};
|
||||
|
||||
const merged = mergeFlowDefinitions(originalFlow, patchWithSteps);
|
||||
|
||||
expect(merged.steps.step1).toEqual(
|
||||
expect.objectContaining({
|
||||
handler: originalFlow.steps.step1.handler,
|
||||
newProperty: 'value',
|
||||
}),
|
||||
);
|
||||
expect(merged.steps.step2).toEqual(originalFlow.steps.step2);
|
||||
expect(merged.steps.step3).toEqual(patchWithSteps.steps.step3);
|
||||
});
|
||||
|
||||
test('should create new steps when they do not exist in original', () => {
|
||||
const patchWithNewStep = {
|
||||
steps: {
|
||||
newStep: { handler: vi.fn().mockReturnValue('new-result') },
|
||||
},
|
||||
};
|
||||
|
||||
const merged = mergeFlowDefinitions(originalFlow, patchWithNewStep);
|
||||
|
||||
expect(merged.steps.newStep).toEqual(patchWithNewStep.steps.newStep);
|
||||
expect(merged.steps.step1).toEqual(originalFlow.steps.step1);
|
||||
});
|
||||
|
||||
test('should handle empty steps in patch', () => {
|
||||
const merged = mergeFlowDefinitions(originalFlow, { steps: {} });
|
||||
|
||||
expect(merged.steps).toEqual(originalFlow.steps);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== isInheritedFrom() FUNCTION ====================
|
||||
describe('isInheritedFrom()', () => {
|
||||
let BaseClass: ModelConstructor;
|
||||
let MiddleClass: ModelConstructor;
|
||||
let DerivedClass: ModelConstructor;
|
||||
let UnrelatedClass: ModelConstructor;
|
||||
|
||||
beforeEach(() => {
|
||||
BaseClass = class extends FlowModel {} as ModelConstructor;
|
||||
MiddleClass = class MiddleClass extends BaseClass {} as ModelConstructor;
|
||||
DerivedClass = class DerivedClass extends MiddleClass {} as ModelConstructor;
|
||||
UnrelatedClass = class extends FlowModel {} as ModelConstructor;
|
||||
});
|
||||
|
||||
describe('basic inheritance checking', () => {
|
||||
test('should return true for direct inheritance', () => {
|
||||
const result = isInheritedFrom(MiddleClass, BaseClass);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for same class', () => {
|
||||
const result = isInheritedFrom(BaseClass, BaseClass);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for unrelated classes', () => {
|
||||
const result = isInheritedFrom(UnrelatedClass, BaseClass);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-level inheritance', () => {
|
||||
test('should return true for multi-level inheritance', () => {
|
||||
const result = isInheritedFrom(DerivedClass, BaseClass);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for immediate parent', () => {
|
||||
const result = isInheritedFrom(DerivedClass, MiddleClass);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle deep inheritance chains', () => {
|
||||
class Level1 extends DerivedClass {}
|
||||
class Level2 extends Level1 {}
|
||||
class Level3 extends Level2 {}
|
||||
|
||||
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, BaseClass)).toBe(true);
|
||||
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, MiddleClass)).toBe(true);
|
||||
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, DerivedClass)).toBe(true);
|
||||
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, Level2 as unknown as ModelConstructor)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prototype chain validation', () => {
|
||||
test('should traverse prototype chain correctly', () => {
|
||||
// Create a complex inheritance chain
|
||||
const A = class extends FlowModel {} as ModelConstructor;
|
||||
const B = class B extends A {} as ModelConstructor;
|
||||
const C = class C extends B {} as ModelConstructor;
|
||||
const D = class D extends C {} as ModelConstructor;
|
||||
|
||||
expect(isInheritedFrom(D, C)).toBe(true);
|
||||
expect(isInheritedFrom(D, B)).toBe(true);
|
||||
expect(isInheritedFrom(D, A)).toBe(true);
|
||||
expect(isInheritedFrom(C, A)).toBe(true);
|
||||
expect(isInheritedFrom(B, A)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle null prototype correctly', () => {
|
||||
const NullProtoClass = function () {} as unknown as ModelConstructor;
|
||||
Object.setPrototypeOf((NullProtoClass as unknown as { prototype: unknown }).prototype, null);
|
||||
|
||||
expect(() => {
|
||||
isInheritedFrom(NullProtoClass, BaseClass);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== resolveDefaultParams() FUNCTION ====================
|
||||
describe('resolveDefaultParams()', () => {
|
||||
let mockContext: ParamsContext<FlowModel>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
model: mockModel,
|
||||
globals: {
|
||||
flowEngine: mockFlowEngine,
|
||||
app: {},
|
||||
},
|
||||
app: {} as any,
|
||||
extra: { testExtra: 'value' },
|
||||
};
|
||||
});
|
||||
|
||||
describe('static parameter resolution', () => {
|
||||
test('should return static object directly', async () => {
|
||||
const staticParams = { param1: 'value1', param2: 'value2' };
|
||||
|
||||
const result = await resolveDefaultParams(staticParams, mockContext);
|
||||
|
||||
expect(result).toEqual(staticParams);
|
||||
});
|
||||
|
||||
test('should return empty object for undefined params', async () => {
|
||||
const result = await resolveDefaultParams(undefined, mockContext);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('should return empty object for null params', async () => {
|
||||
const result = await resolveDefaultParams(null, mockContext);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle complex static objects', async () => {
|
||||
const complexParams = {
|
||||
user: { name: 'John', age: 30 },
|
||||
settings: { theme: 'dark', notifications: true },
|
||||
array: [1, 2, 3],
|
||||
};
|
||||
|
||||
const result = await resolveDefaultParams(complexParams, mockContext);
|
||||
|
||||
expect(result).toEqual(complexParams);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function parameter resolution', () => {
|
||||
test('should call function with context and return result', async () => {
|
||||
const paramsFn = vi.fn().mockReturnValue({ dynamic: 'value' });
|
||||
|
||||
const result = await resolveDefaultParams(paramsFn, mockContext);
|
||||
|
||||
expect(paramsFn).toHaveBeenCalledWith(mockContext);
|
||||
expect(result).toEqual({ dynamic: 'value' });
|
||||
});
|
||||
|
||||
test('should handle function accessing context properties', async () => {
|
||||
const paramsFn = vi.fn((ctx: ParamsContext<FlowModel>) => ({
|
||||
modelUid: ctx.model.uid,
|
||||
extraData: ctx.extra.testExtra,
|
||||
}));
|
||||
|
||||
const result = await resolveDefaultParams(paramsFn, mockContext);
|
||||
|
||||
expect(result).toEqual({
|
||||
modelUid: mockModel.uid,
|
||||
extraData: 'value',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('async processing', () => {
|
||||
test('should handle async function correctly', async () => {
|
||||
const asyncParamsFn = vi.fn().mockResolvedValue({ async: 'result' });
|
||||
|
||||
const result = await resolveDefaultParams(asyncParamsFn, mockContext);
|
||||
|
||||
expect(asyncParamsFn).toHaveBeenCalledWith(mockContext);
|
||||
expect(result).toEqual({ async: 'result' });
|
||||
});
|
||||
|
||||
test('should handle async function with delay', async () => {
|
||||
const asyncParamsFn = vi.fn(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ delayed: 'value' }), 10)),
|
||||
);
|
||||
|
||||
const result = await resolveDefaultParams(asyncParamsFn, mockContext);
|
||||
|
||||
expect(result).toEqual({ delayed: 'value' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== resolveUiSchema() FUNCTION ====================
|
||||
describe('resolveUiSchema()', () => {
|
||||
let mockContext: ParamsContext<FlowModel>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
model: mockModel,
|
||||
globals: {
|
||||
flowEngine: mockFlowEngine,
|
||||
app: {},
|
||||
},
|
||||
app: {} as any,
|
||||
extra: { testExtra: 'value' },
|
||||
};
|
||||
});
|
||||
|
||||
describe('static schema resolution', () => {
|
||||
test('should return static schema object directly', async () => {
|
||||
const staticSchema: Record<string, ISchema> = {
|
||||
field1: { type: 'string', title: 'Field 1' },
|
||||
field2: { type: 'number', title: 'Field 2' },
|
||||
};
|
||||
|
||||
const result = await resolveUiSchema(staticSchema, mockContext);
|
||||
|
||||
expect(result).toEqual(staticSchema);
|
||||
});
|
||||
|
||||
test('should return empty object for undefined schema', async () => {
|
||||
const result = await resolveUiSchema(undefined, mockContext);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('should return empty object for null schema', async () => {
|
||||
const result = await resolveUiSchema(null, mockContext);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle complex static schema', async () => {
|
||||
const complexSchema: Record<string, ISchema> = {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: 'Name' },
|
||||
age: { type: 'number', title: 'Age' },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
type: 'object',
|
||||
'x-component': 'FormLayout',
|
||||
properties: {
|
||||
theme: { type: 'string', enum: ['light', 'dark'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveUiSchema(complexSchema, mockContext);
|
||||
|
||||
expect(result).toEqual(complexSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function schema resolution', () => {
|
||||
test('should call function with context and return result', async () => {
|
||||
const schemaFn = vi.fn().mockReturnValue({
|
||||
dynamicField: { type: 'string', title: 'Dynamic Field' },
|
||||
});
|
||||
|
||||
const result = await resolveUiSchema(schemaFn, mockContext);
|
||||
|
||||
expect(schemaFn).toHaveBeenCalledWith(mockContext);
|
||||
expect(result).toEqual({
|
||||
dynamicField: { type: 'string', title: 'Dynamic Field' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle function accessing context properties', async () => {
|
||||
const schemaFn = vi.fn((ctx: ParamsContext<FlowModel>) => ({
|
||||
modelInfo: {
|
||||
type: 'string',
|
||||
title: 'Model UID',
|
||||
default: ctx.model.uid,
|
||||
},
|
||||
extraInfo: {
|
||||
type: 'string',
|
||||
title: 'Extra Data',
|
||||
default: ctx.extra.testExtra,
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await resolveUiSchema(schemaFn, mockContext);
|
||||
|
||||
expect(result.modelInfo.default).toBe(mockModel.uid);
|
||||
expect(result.extraInfo.default).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('async processing', () => {
|
||||
test('should handle async function correctly', async () => {
|
||||
const asyncSchemaFn = vi.fn().mockResolvedValue({
|
||||
asyncField: { type: 'string', title: 'Async Field' },
|
||||
});
|
||||
|
||||
const result = await resolveUiSchema(asyncSchemaFn, mockContext);
|
||||
|
||||
expect(asyncSchemaFn).toHaveBeenCalledWith(mockContext);
|
||||
expect(result).toEqual({
|
||||
asyncField: { type: 'string', title: 'Async Field' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle async function with delay', async () => {
|
||||
const asyncSchemaFn = vi.fn(
|
||||
() =>
|
||||
new Promise<any>((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
delayedField: { type: 'number', title: 'Delayed Field' },
|
||||
}),
|
||||
10,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await resolveUiSchema(asyncSchemaFn, mockContext);
|
||||
|
||||
expect(result).toEqual({
|
||||
delayedField: { type: 'number', title: 'Delayed Field' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== FlowExitException CLASS ====================
|
||||
describe('FlowExitException', () => {
|
||||
describe('constructor', () => {
|
||||
test('should create exception with all parameters', () => {
|
||||
const exception = new FlowExitException('testFlow', 'model-123', 'Custom exit message');
|
||||
|
||||
expect(exception.flowKey).toBe('testFlow');
|
||||
expect(exception.modelUid).toBe('model-123');
|
||||
expect(exception.message).toBe('Custom exit message');
|
||||
expect(exception.name).toBe('FlowExitException');
|
||||
});
|
||||
|
||||
test('should create exception with default message', () => {
|
||||
const exception = new FlowExitException('testFlow', 'model-123');
|
||||
|
||||
expect(exception.flowKey).toBe('testFlow');
|
||||
expect(exception.modelUid).toBe('model-123');
|
||||
expect(exception.message).toBe("Flow 'testFlow' on model 'model-123' exited via ctx.exit().");
|
||||
expect(exception.name).toBe('FlowExitException');
|
||||
});
|
||||
|
||||
test('should create exception with empty string message', () => {
|
||||
const exception = new FlowExitException('testFlow', 'model-123', '');
|
||||
|
||||
// Empty string is falsy, so the default message is used in the constructor
|
||||
expect(exception.message).toBe("Flow 'testFlow' on model 'model-123' exited via ctx.exit().");
|
||||
});
|
||||
});
|
||||
|
||||
describe('property access', () => {
|
||||
test('should have readonly properties accessible', () => {
|
||||
const exception = new FlowExitException('flowKey', 'modelUid');
|
||||
|
||||
expect(exception.flowKey).toBe('flowKey');
|
||||
expect(exception.modelUid).toBe('modelUid');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== defineAction() FUNCTION ====================
|
||||
describe('defineAction()', () => {
|
||||
describe('basic functionality', () => {
|
||||
test('should return action definition unchanged', () => {
|
||||
const actionDef: ActionDefinition = {
|
||||
name: 'testAction',
|
||||
handler: vi.fn(),
|
||||
};
|
||||
|
||||
const result = defineAction(actionDef);
|
||||
|
||||
expect(result).toBe(actionDef);
|
||||
expect(result).toEqual(actionDef);
|
||||
});
|
||||
|
||||
test('should handle complex action definition', () => {
|
||||
const complexAction: ActionDefinition = {
|
||||
name: 'complexAction',
|
||||
handler: vi.fn().mockResolvedValue('result'),
|
||||
defaultParams: { param1: 'value1' },
|
||||
uiSchema: {
|
||||
field1: { type: 'string', title: 'Field 1' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = defineAction(complexAction);
|
||||
|
||||
expect(result).toBe(complexAction);
|
||||
expect(result.name).toBe('complexAction');
|
||||
expect(result.defaultParams).toEqual({ param1: 'value1' });
|
||||
expect(result.uiSchema).toEqual({
|
||||
field1: { type: 'string', title: 'Field 1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== compileUiSchema() FUNCTION ====================
|
||||
describe('compileUiSchema()', () => {
|
||||
let mockScope: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockScope = {
|
||||
t: vi.fn((key: string) => `translated_${key}`),
|
||||
randomString: vi.fn(() => 'random123'),
|
||||
user: { name: 'John', role: 'admin' },
|
||||
};
|
||||
});
|
||||
|
||||
describe('expression compilation', () => {
|
||||
test('should compile simple expressions', () => {
|
||||
const result = compileUiSchema(mockScope, "{{ t('Hello World') }}");
|
||||
|
||||
expect(mockScope.t).toHaveBeenCalledWith('Hello World');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
test('should compile expressions with variables', () => {
|
||||
const result = compileUiSchema(mockScope, '{{ user.name }}');
|
||||
|
||||
expect(result).toBe('John');
|
||||
});
|
||||
|
||||
test('should compile complex expressions', () => {
|
||||
const result = compileUiSchema(mockScope, "{{ user.role === 'admin' ? 'Administrator' : 'User' }}");
|
||||
|
||||
expect(result).toBe('Administrator');
|
||||
});
|
||||
|
||||
test('should handle non-expression strings', () => {
|
||||
const result = compileUiSchema(mockScope, 'Plain string without expressions');
|
||||
|
||||
expect(result).toBe('Plain string without expressions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching mechanism', () => {
|
||||
test('should cache compiled results', () => {
|
||||
const schema = "{{ t('Cached Test') }}";
|
||||
|
||||
const result1 = compileUiSchema(mockScope, schema);
|
||||
const result2 = compileUiSchema(mockScope, schema);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
// Schema.compile should be called once and then cached
|
||||
expect(mockScope.t).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should bypass cache when noCache option is true', () => {
|
||||
const schema = "{{ t('No Cache Test') }}";
|
||||
|
||||
compileUiSchema(mockScope, schema, { noCache: false });
|
||||
compileUiSchema(mockScope, schema, { noCache: true });
|
||||
|
||||
// t function should be called twice when bypassing cache
|
||||
expect(mockScope.t).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should cache object compilations', () => {
|
||||
const schema = {
|
||||
title: "{{ t('Object Title') }}",
|
||||
description: 'Static description',
|
||||
};
|
||||
|
||||
const result1 = compileUiSchema(mockScope, schema);
|
||||
const result2 = compileUiSchema(mockScope, schema);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
expect(result1.title).toBeDefined();
|
||||
expect(result1.description).toBe('Static description');
|
||||
});
|
||||
|
||||
test('should cache array compilations', () => {
|
||||
const schema = [{ title: "{{ t('Item 1') }}" }, { title: "{{ t('Item 2') }}" }];
|
||||
|
||||
const result1 = compileUiSchema(mockScope, schema);
|
||||
const result2 = compileUiSchema(mockScope, schema);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
expect(Array.isArray(result1)).toBe(true);
|
||||
expect(result1).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('object compilation', () => {
|
||||
test('should compile objects with template strings', () => {
|
||||
const schema = {
|
||||
title: "{{ t('Form Title') }}",
|
||||
description: 'Static description',
|
||||
user: '{{ user.name }}',
|
||||
role: '{{ user.role }}',
|
||||
};
|
||||
|
||||
const result = compileUiSchema(mockScope, schema);
|
||||
|
||||
expect(typeof result.title).toBe('string');
|
||||
expect(result.description).toBe('Static description');
|
||||
expect(result.user).toBe('John');
|
||||
expect(result.role).toBe('admin');
|
||||
});
|
||||
|
||||
test('should handle nested objects', () => {
|
||||
const schema = {
|
||||
form: {
|
||||
title: "{{ t('Nested Form') }}",
|
||||
fields: {
|
||||
username: {
|
||||
label: "{{ t('Username') }}",
|
||||
placeholder: "{{ t('Enter username') }}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = compileUiSchema(mockScope, schema);
|
||||
|
||||
expect(typeof result.form.title).toBe('string');
|
||||
expect(typeof result.form.fields.username.label).toBe('string');
|
||||
expect(typeof result.form.fields.username.placeholder).toBe('string');
|
||||
});
|
||||
|
||||
test('should handle arrays within objects', () => {
|
||||
const schema = {
|
||||
items: [
|
||||
{ title: "{{ t('Item 1') }}", value: 1 },
|
||||
{ title: "{{ t('Item 2') }}", value: 2 },
|
||||
],
|
||||
metadata: {
|
||||
count: "{{ user.role === 'admin' ? 'unlimited' : '10' }}",
|
||||
},
|
||||
};
|
||||
|
||||
const result = compileUiSchema(mockScope, schema);
|
||||
|
||||
expect(Array.isArray(result.items)).toBe(true);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0].value).toBe(1);
|
||||
expect(result.items[1].value).toBe(2);
|
||||
expect(result.metadata.count).toBe('unlimited');
|
||||
});
|
||||
|
||||
test('should preserve non-template properties', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: "{{ t('Name Field') }}",
|
||||
maxLength: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = compileUiSchema(mockScope, schema);
|
||||
|
||||
expect(result.type).toBe('object');
|
||||
expect(result.properties.name.type).toBe('string');
|
||||
expect(typeof result.properties.name.title).toBe('string');
|
||||
expect(result.properties.name.maxLength).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user