Skip to content

Commit

Permalink
test: Improve BPMN editor tests (#14421)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasEng authored Jan 20, 2025
1 parent 1cf0f2e commit 6795c45
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 131 deletions.
343 changes: 221 additions & 122 deletions frontend/packages/process-editor/src/hooks/useBpmnEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,168 +1,267 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import type { RenderHookResult } from '@testing-library/react';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { UseBpmnEditorResult } from './useBpmnEditor';
import { useBpmnEditor } from './useBpmnEditor';
import { BpmnContextProvider } from '../contexts/BpmnContext';
import type { BpmnContextProviderProps } from '../contexts/BpmnContext';
import { BpmnContextProvider, useBpmnContext } from '../contexts/BpmnContext';
import type { BpmnApiContextProps } from '../contexts/BpmnApiContext';
import { BpmnApiContextProvider } from '../contexts/BpmnApiContext';
import { useBpmnModeler } from './useBpmnModeler';
import type { BpmnDetails } from '../types/BpmnDetails';
import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse';
import { getMockBpmnElementForTask, mockBpmnDetails } from '../../test/mocks/bpmnDetailsMock';
import { getBpmnEditorDetailsFromBusinessObject } from '../utils/bpmnObjectBuilders';
import { mockBpmnDetails } from '../../test/mocks/bpmnDetailsMock';
import { StudioRecommendedNextActionContextProvider } from '@studio/components';
import { BpmnConfigPanelFormContextProvider } from '../contexts/BpmnConfigPanelContext';
import type { TaskEvent } from '../types/TaskEvent';
import { EventListeners } from '../../test/EventListeners';
import type {
BpmnBusinessObjectEditor,
BpmnExtensionElementsEditor,
} from '@altinn/process-editor/types/BpmnBusinessObjectEditor';
import { BpmnTypeEnum } from '../enum/BpmnTypeEnum';
import type { BpmnTaskType } from '../types/BpmnTaskType';
import type { OnProcessTaskEvent } from '@altinn/process-editor/types/OnProcessTask';
import type { BpmnDetails } from '@altinn/process-editor/types/BpmnDetails';
import type { SelectionChangedEvent } from '@altinn/process-editor/types/SelectionChangeEvent';
import type BpmnModeler from 'bpmn-js/lib/Modeler';

// Test data:
const appLibVersion = '8.0.0';
const defaultBpmnContextProps: Omit<BpmnContextProviderProps, 'children'> = {
appLibVersion,
bpmnXml: undefined,
};
const layoutSetId = 'someLayoutSetId';
const layoutSetsMock: LayoutSets = {
const layoutSets: LayoutSets = {
sets: [
{
id: layoutSetId,
tasks: [mockBpmnDetails.id],
},
],
};
const defaultBpmnApiContextProps: BpmnApiContextProps = {
availableDataTypeIds: [],
availableDataModelIds: [],
allDataModelIds: [],
layoutSets,
pendingApiOperations: false,
existingCustomReceiptLayoutSetId: undefined,
addLayoutSet: jest.fn(),
deleteLayoutSet: jest.fn(),
mutateLayoutSetId: jest.fn(),
mutateDataTypes: jest.fn(),
saveBpmn: jest.fn(),
openPolicyEditor: jest.fn(),
onProcessTaskAdd: jest.fn(),
onProcessTaskRemove: jest.fn(),
};
const taskType: BpmnTaskType = 'data';
const extensionElements: BpmnExtensionElementsEditor = {
values: [
{
$type: 'altinn:TaskExtension',
taskType,
},
],
};
const businessObject: BpmnBusinessObjectEditor = {
$type: BpmnTypeEnum.Task,
id: 'test',
extensionElements,
};
const element: TaskEvent['element'] = {
id: 'test',
businessObject,
};
const xml = '<testxml></testxml>';

class BpmnModelerMockImpl {
public readonly _currentEventName: string;
public readonly _currentEvent: any;
private readonly eventBus: any;

constructor(currentEventName: string, currentEvent) {
this._currentEventName = currentEventName;
this._currentEvent = currentEvent;
this.eventBus = {
_currentEventName: this._currentEventName,
on: this.on,
};
}

on(eventName: string, listener: (event: any) => void) {
if (eventName === this._currentEventName) {
listener(this._currentEvent);
}
}

get(elementName: string) {
if (elementName === 'eventBus') {
return this.eventBus;
}
}
}

jest.mock('../utils/bpmnObjectBuilders', () => ({
getBpmnEditorDetailsFromBusinessObject: jest.fn().mockReturnValue({}),
}));

jest.mock('../contexts/BpmnConfigPanelContext', () => ({
useBpmnConfigPanelFormContext: jest.fn(() => ({
metadataFormRef: { current: null },
resetForm: jest.fn(),
})),
}));
// Mocks:
jest.mock('bpmn-js/lib/Modeler', () => jest.fn().mockImplementation(bpmnModelerImplementation));

jest.mock('../contexts/BpmnContext', () => ({
...jest.requireActual('../contexts/BpmnContext'),
useBpmnContext: jest.fn(() => ({
getUpdatedXml: jest.fn(),
modelerRef: { current: null },
setBpmnDetails: setBpmnDetailsMock,
})),
}));
function bpmnModelerImplementation(): BpmnModeler {
return {
get: getModeler,
importXML,
on,
off,
saveXML,
attachTo: jest.fn(),
clear: jest.fn(),
createDiagram: jest.fn(),
destroy: jest.fn(),
detach: jest.fn(),
getDefinitions: jest.fn(),
getModules: jest.fn(),
importDefinitions: jest.fn(),
invoke: jest.fn(),
open: jest.fn(),
saveSVG: jest.fn(),
};
}

jest.mock('./useBpmnModeler', () => ({
useBpmnModeler: jest.fn().mockReturnValue({}),
const getModeler = jest.fn().mockImplementation(() => ({
zoom: () => {},
}));

const setBpmnDetailsMock = jest.fn();
const onProcessTaskAddMock = jest.fn();
const onProcessTaskRemoveMock = jest.fn();

const overrideUseBpmnModeler = (currentEventName: string, currentEvent: any) => {
(useBpmnModeler as jest.Mock).mockReturnValue({
getModeler: () => new BpmnModelerMockImpl(currentEventName, currentEvent),
destroyModeler: jest.fn(),
const importXML = jest.fn().mockImplementation(() => Promise.resolve({ warnings: [] }));
const on = jest
.fn()
.mockImplementation(<K extends keyof EventMap>(eventName: K, callback: EventMap[K]): void => {
eventListeners.add(eventName, callback);
});
};
const off = jest
.fn()
.mockImplementation(<K extends keyof EventMap>(eventName: K, callback: EventMap[K]): void => {
eventListeners.remove(eventName, callback);
});
const saveXML = jest.fn().mockImplementation(() => Promise.resolve({ xml }));

const overrideGetBpmnEditorDetailsFromBusinessObject = (bpmnDetails: BpmnDetails) => {
(getBpmnEditorDetailsFromBusinessObject as jest.Mock).mockReturnValue(bpmnDetails);
};
const eventListeners = new EventListeners<EventMap>();

const wrapper = ({ children }) => (
<BpmnContextProvider appLibVersion={'8.0.0'}>
<BpmnApiContextProvider
addLayoutSet={jest.fn()}
deleteLayoutSet={jest.fn()}
saveBpmn={saveBpmnMock}
onProcessTaskAdd={onProcessTaskAddMock}
onProcessTaskRemove={onProcessTaskRemoveMock}
layoutSets={layoutSetsMock}
>
<StudioRecommendedNextActionContextProvider>
{children}
</StudioRecommendedNextActionContextProvider>
</BpmnApiContextProvider>
</BpmnContextProvider>
);

const saveBpmnMock = jest.fn();
type EventMap = {
['commandStack.changed']: () => void;
['shape.added']: (taskEvent: TaskEvent) => void;
['shape.remove']: (taskEvent: TaskEvent) => void;
['selection.changed']: (selectionChangedEvent: SelectionChangedEvent) => void;
};

describe('useBpmnEditor', () => {
afterEach(() => {
beforeEach(() => {
jest.clearAllMocks();
eventListeners.clear();
});

it('should call saveBpmn when "commandStack.changed" event is triggered on modelerInstance', async () => {
const currentEventName = 'commandStack.changed';
const currentEvent = { element: getMockBpmnElementForTask('data') };
setup(currentEventName, currentEvent);

await waitFor(() => expect(saveBpmnMock).toHaveBeenCalledTimes(1));
it('Calls saveBpmn with correct data when the "commandStack.changed" event is triggered', async () => {
const saveBpmn = jest.fn();
await setup({ bpmnApiContextProps: { saveBpmn } });
eventListeners.triggerEvent('commandStack.changed');
await waitFor(expect(saveBpmn).toHaveBeenCalled);
expect(saveBpmn).toHaveBeenCalledTimes(1);
expect(saveBpmn).toHaveBeenCalledWith(xml, null);
});

it('should handle "shape.added" event', async () => {
const currentEvent = { element: getMockBpmnElementForTask('data') };
setup('shape.added', currentEvent);
it('Calls onProcessTaskAdd with correct data when the "shape.added" event is triggered', async () => {
const onProcessTaskAdd = jest.fn();
const taskEvent: TaskEvent = { element } as TaskEvent;
await setup({ bpmnApiContextProps: { onProcessTaskAdd } });

act(() => eventListeners.triggerEvent('shape.added', taskEvent)); // Need to use act here because this event also triggers the addAction function from useStudioRecommendedNextActionContext, which in turn triggers another state update
await waitFor(expect(onProcessTaskAdd).toHaveBeenCalled);

await waitFor(() => expect(onProcessTaskAddMock).toHaveBeenCalledTimes(1));
const expectedInput: OnProcessTaskEvent = { taskEvent, taskType };
expect(onProcessTaskAdd).toHaveBeenCalledTimes(1);
expect(onProcessTaskAdd).toHaveBeenCalledWith(expectedInput);
});

it('should handle "shape.remove" event', async () => {
const currentEvent = { element: getMockBpmnElementForTask('data') };
setup('shape.remove', currentEvent);
it('Calls onProcessTaskRemove with correct data when the "shape.remove" event is triggered', async () => {
const onProcessTaskRemove = jest.fn();
const taskEvent: TaskEvent = { element } as TaskEvent;
await setup({ bpmnApiContextProps: { onProcessTaskRemove } });

await waitFor(() => expect(onProcessTaskRemoveMock).toHaveBeenCalledTimes(1));
eventListeners.triggerEvent('shape.remove', taskEvent);
await waitFor(expect(onProcessTaskRemove).toHaveBeenCalled);

const expectedInput: OnProcessTaskEvent = { taskEvent, taskType };
expect(onProcessTaskRemove).toHaveBeenCalledTimes(1);
expect(onProcessTaskRemove).toHaveBeenCalledWith(expectedInput);
});

it('should call setBpmnDetails with selected object when "selection.changed" event is triggered with new selection', async () => {
const currentEventName = 'selection.changed';
const currentEvent = { newSelection: [getMockBpmnElementForTask('data')], oldSelection: [] };
setup(currentEventName, currentEvent);
it('Updates BPMN details with selected object when "selection.changed" event is triggered with new selection', async () => {
const selectionChangedEvent: SelectionChangedEvent = {
oldSelection: [],
newSelection: [element],
};
const { result } = await setupWithBpmnDetails();
act(() => eventListeners.triggerEvent('selection.changed', selectionChangedEvent));
expect(result.current.bpmnDetails.element).toEqual(element);
});

await waitFor(() => expect(setBpmnDetailsMock).toHaveBeenCalledTimes(1));
expect(setBpmnDetailsMock).toHaveBeenCalledWith(expect.objectContaining(mockBpmnDetails));
it('Updates BPMN details with null when "selection.changed" event is triggered with no new selected object', async () => {
const selectionChangedEvent: SelectionChangedEvent = {
oldSelection: [element],
newSelection: [],
};
const { result } = await setupWithBpmnDetails();
act(() => eventListeners.triggerEvent('selection.changed', selectionChangedEvent));
expect(result.current.bpmnDetails).toBe(null);
});

it('should call setBpmnDetails with null when "selection.changed" event is triggered with no new selected object', async () => {
const currentEventName = 'selection.changed';
const currentEvent = { oldSelection: [getMockBpmnElementForTask('data')], newSelection: [] };
setup(currentEventName, currentEvent);
// Todo: Remove skip when this test passes. Fixing this will resolve https://github.com/Altinn/altinn-studio/issues/13035.
it.skip('Calls only the most recent saveBpmn function when the "commandStack.changed" event is triggered', async () => {
const saveBpmn1 = jest.fn();
const saveBpmn2 = jest.fn();
const bpmnApiContextProps: Partial<BpmnApiContextProps> = {
saveBpmn: saveBpmn1,
};

const { rerender } = await setup({ bpmnApiContextProps });
bpmnApiContextProps.saveBpmn = saveBpmn2;
rerender();

await waitFor(() => expect(setBpmnDetailsMock).toHaveBeenCalledTimes(1));
expect(setBpmnDetailsMock).toHaveBeenCalledWith(null);
eventListeners.triggerEvent('commandStack.changed');
await waitFor(expect(saveBpmn2).toHaveBeenCalled);
expect(saveBpmn1).not.toHaveBeenCalled();
expect(saveBpmn2).toHaveBeenCalledTimes(1);
});
});

function setup(...params: Parameters<typeof renderUseBpmnEditor>): void {
const div = document.createElement('div');
const { result } = renderUseBpmnEditor(...params);
type BpmnProviderProps = {
bpmnApiContextProps: Partial<BpmnApiContextProps>;
};

async function setup(
props?: Partial<BpmnProviderProps>,
): Promise<RenderHookResult<UseBpmnEditorResult, void>> {
const utils = renderUseBpmnEditor(props);
const { result } = utils;
const div: HTMLDivElement = document.createElement('div');
result.current(div);
await waitFor(expect(getModeler).toHaveBeenCalled);
return utils;
}

const renderUseBpmnEditor = (
currentEventName: string,
currentEvent: any,
bpmnDetails = mockBpmnDetails,
) => {
overrideGetBpmnEditorDetailsFromBusinessObject(bpmnDetails);
overrideUseBpmnModeler(currentEventName, currentEvent);
function renderUseBpmnEditor(
props: Partial<BpmnProviderProps> = {},
): RenderHookResult<UseBpmnEditorResult, void> {
const wrapper = ({ children }) => renderWithBpmnProviders(children, props);
return renderHook(() => useBpmnEditor(), { wrapper });
}

function renderWithBpmnProviders(
children: React.ReactNode,
props: Partial<BpmnProviderProps> = {},
): React.ReactElement {
return (
<BpmnContextProvider {...defaultBpmnContextProps}>
<BpmnConfigPanelFormContextProvider>
<BpmnApiContextProvider {...defaultBpmnApiContextProps} {...props?.bpmnApiContextProps}>
<StudioRecommendedNextActionContextProvider>
{children}
</StudioRecommendedNextActionContextProvider>
</BpmnApiContextProvider>
</BpmnConfigPanelFormContextProvider>
</BpmnContextProvider>
);
}

async function setupWithBpmnDetails(): Promise<
RenderHookResult<UseBpmnEditorAndDetailsResult, void>
> {
const wrapper = ({ children }) => renderWithBpmnProviders(children);
const utils = renderHook(() => useBpmnEditorAndDetails(), { wrapper });
const { result } = utils;
const div = document.createElement('div');
result.current.bpmnEditor(div);
await waitFor(expect(getModeler).toHaveBeenCalled);
return utils;
}

type UseBpmnEditorAndDetailsResult = {
bpmnEditor: UseBpmnEditorResult;
bpmnDetails: BpmnDetails;
};

const useBpmnEditorAndDetails = (): UseBpmnEditorAndDetailsResult => {
const bpmnEditor = useBpmnEditor();
const { bpmnDetails } = useBpmnContext();
return { bpmnEditor, bpmnDetails };
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useStudioRecommendedNextActionContext } from '@studio/components';

// Wrapper around bpmn-js to Reactify it

type UseBpmnEditorResult = (div: HTMLDivElement) => void;
export type UseBpmnEditorResult = (div: HTMLDivElement) => void;

export const useBpmnEditor = (): UseBpmnEditorResult => {
const { getUpdatedXml, bpmnXml, modelerRef, setBpmnDetails } = useBpmnContext();
Expand Down
Loading

0 comments on commit 6795c45

Please sign in to comment.