Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 144 additions & 7 deletions test/integration/diagram.navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,163 @@ limitations under the License.
*/

import { initializeBpmnVisualizationWithHtmlElement } from './helpers/bpmn-visualization-initialization';
import { allTestedFitTypes } from './helpers/fit-utils';

import { type FitType, ZoomType } from '@lib/component/options';
import { mxRectangle } from '@lib/component/mxgraph/initializer';
import { FitType, ZoomType } from '@lib/component/options';
import { readFileSync } from '@test/shared/file-helper';

const bpmnVisualization = initializeBpmnVisualizationWithHtmlElement('bpmn-container', true);

type ContainerDimensions = Partial<Record<'clientWidth' | 'clientHeight' | 'offsetWidth' | 'offsetHeight', number>>;

// jsdom always reports 0 for the container dimensions, so force them to assert the scale/translation computed by 'fit'.
const setContainerDimensions = (container: HTMLElement, dimensions: ContainerDimensions): void => {
for (const [name, value] of Object.entries(dimensions)) {
Object.defineProperty(container, name, { value, configurable: true });
}
};

describe('diagram navigation', () => {
beforeEach(() => {
bpmnVisualization.load(readFileSync('../fixtures/bpmn/simple-start-task-end.bpmn'));
});

// The following tests ensure there is no error when calling the fit method
describe('Fit', () => {
it('Fit no options', () => {
bpmnVisualization.navigation.fit();
// The rendered graph bounds are not predictable, so mock them to assert the exact scale and translation computed by
// 'fit'. Combined with 'setContainerDimensions', this reproduces the approach of the maxGraph 'FitPlugin' tests:
// configure the container dimensions, then verify the calls to the underlying mxGraphView methods.
const view = bpmnVisualization.graph.view;
let getGraphBoundsSpy: jest.SpyInstance | undefined;

const configureFitScenario = (
dimensions: ContainerDimensions,
bounds: { x: number; y: number; width: number; height: number },
): { scaleAndTranslateSpy: jest.SpyInstance; setScaleSpy: jest.SpyInstance } => {
setContainerDimensions(bpmnVisualization.graph.container, dimensions);
// Mock the bounds getter (not 'setGraphBounds', which the 'zoomActual' call in 'fit' would revalidate and clobber)
// to make the scale/translation deterministic and decoupled from the parse/render pipeline. The full computation
// on real bounds is covered by the e2e visual tests.
getGraphBoundsSpy = jest.spyOn(view, 'getGraphBounds').mockReturnValue(new mxRectangle(bounds.x, bounds.y, bounds.width, bounds.height));
return {
scaleAndTranslateSpy: jest.spyOn(view, 'scaleAndTranslate').mockClear(),
setScaleSpy: jest.spyOn(view, 'setScale').mockClear(),
};
};

afterEach(() => {
// Restore only the bounds mock to avoid leaking it into other tests sharing the same instance.
getGraphBoundsSpy?.mockRestore();
getGraphBoundsSpy = undefined;
});

describe('No scaling', () => {
// 'null' is loosely equal to 'undefined' in the implementation, so both trigger the early return.
it.each([undefined, null, FitType.None])('Fit with %s does not scale the diagram', (type: FitType | null | undefined) => {
const scaleAndTranslateSpy = jest.spyOn(view, 'scaleAndTranslate').mockClear();
const setScaleSpy = jest.spyOn(view, 'setScale').mockClear();

bpmnVisualization.navigation.fit({ type: type as FitType });

expect(scaleAndTranslateSpy).not.toHaveBeenCalled();
expect(setScaleSpy).not.toHaveBeenCalled();
expect(view.scale).toBe(1);
});

it('Fit without options does not scale the diagram', () => {
const scaleAndTranslateSpy = jest.spyOn(view, 'scaleAndTranslate').mockClear();
const setScaleSpy = jest.spyOn(view, 'setScale').mockClear();

bpmnVisualization.navigation.fit();

expect(scaleAndTranslateSpy).not.toHaveBeenCalled();
expect(setScaleSpy).not.toHaveBeenCalled();
expect(view.scale).toBe(1);
});
});

describe('Center', () => {
// Center uses the container 'client' dimensions and computes the scale and translation itself.
it('Center the diagram', () => {
const { scaleAndTranslateSpy, setScaleSpy } = configureFitScenario({ clientWidth: 200, clientHeight: 200 }, { x: 0, y: 0, width: 100, height: 100 });

bpmnVisualization.navigation.fit({ type: FitType.Center });

expect(scaleAndTranslateSpy).toHaveBeenCalledExactlyOnceWith(2, 0, 0);
expect(setScaleSpy).not.toHaveBeenCalled();
expect(view.scale).toBe(2);
});

it('Limit the scale to the maximum value (3)', () => {
const { scaleAndTranslateSpy } = configureFitScenario({ clientWidth: 2000, clientHeight: 2000 }, { x: 0, y: 0, width: 100, height: 100 });

bpmnVisualization.navigation.fit({ type: FitType.Center });

expect(scaleAndTranslateSpy).toHaveBeenCalledExactlyOnceWith(3, expect.closeTo(283.33), expect.closeTo(283.33));
expect(view.scale).toBe(3);
});

it('Take the margin into account', () => {
const { scaleAndTranslateSpy } = configureFitScenario({ clientWidth: 200, clientHeight: 200 }, { x: 0, y: 0, width: 100, height: 100 });

bpmnVisualization.navigation.fit({ type: FitType.Center, margin: 30 });

expect(scaleAndTranslateSpy).toHaveBeenCalledExactlyOnceWith(1.7, expect.closeTo(8.82), expect.closeTo(8.82));
expect(view.scale).toBe(1.7);
});

it('Ignore a negative margin (treated as 0)', () => {
const { scaleAndTranslateSpy } = configureFitScenario({ clientWidth: 200, clientHeight: 200 }, { x: 0, y: 0, width: 100, height: 100 });

bpmnVisualization.navigation.fit({ type: FitType.Center, margin: -50 });

expect(scaleAndTranslateSpy).toHaveBeenCalledExactlyOnceWith(2, 0, 0);
expect(view.scale).toBe(2);
});
});

it.each(allTestedFitTypes)('Fit with %s', (fitType: string) => {
bpmnVisualization.navigation.fit({ type: fitType as FitType });
describe('Delegating to the mxGraph fit', () => {
// These types use the container 'offset' dimensions and delegate the computation to mxGraph 'fit'.
// An unknown type behaves like 'HorizontalVertical' (no dimension ignored).
// A positive margin lowers the scale and shifts the translation by 'margin / 2'; it still shifts the translation
// even when the scale is capped to the maximum value (8). A negative margin is treated as 0 (see the 'Center' tests).
it.each`
type | dimensions | bounds | margin | expectedScale | expectedTranslateX | expectedTranslateY
${FitType.HorizontalVertical} | ${{ offsetWidth: 480, offsetHeight: 820 }} | ${{ x: 70, y: -60, width: 100, height: 100 }} | ${undefined} | ${4.78} | ${-70} | ${60}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, but this is a false positive for the test data, so I'll keep the table as is.

it.each here uses a tagged template literal. Unlike a plain template string, a tagged template does not coerce its interpolated expressions: Jest's each receives the raw values and passes them to the test unchanged. The margin cell is therefore the actual undefined, not the string "undefined".

Proof from the suite itself: margin reaches ensurePositiveValue, i.e. Math.max(input ?? 0, 0). With a real undefined this returns 0, which is exactly why the ${undefined} rows assert the no-margin results (4.78 / 5.72 / 8 at translate -70, 60). If the value were the string "undefined", the expression would be Math.max(NaN, 0)NaN, the computed scale would be NaN, and those assertions would fail. They pass, so the value is genuinely undefined.

The only undefined → string conversion happens in the generated test name ($margin → "undefined"), which is intentional: it documents the no-margin case and distinguishes those rows from the margin: 30 / margin: 100 ones. The suggestion on line 142 correctly identifies the title as the sole conversion path; the suggestions on lines 139-141 assume the data itself is converted, which it isn't.

On the proposed workarounds: each trades readability for silencing a rule that misfires on tagged-template semantics. ${null} would also change the documented input (and the Zoom table already interpolates ${null} / ${undefined} on purpose). So I'll resolve this as a false positive rather than change the code.

${FitType.Horizontal} | ${{ offsetWidth: 860, offsetHeight: 860 }} | ${{ x: 70, y: -60, width: 150, height: 300 }} | ${undefined} | ${5.72} | ${-70} | ${60}
Comment thread
tbouffard marked this conversation as resolved.
Dismissed
${FitType.Vertical} | ${{ offsetWidth: 860, offsetHeight: 860 }} | ${{ x: 70, y: -60, width: 1000, height: 100 }} | ${undefined} | ${8} | ${-70} | ${60}
Comment thread
tbouffard marked this conversation as resolved.
Dismissed
${'invalid'} | ${{ offsetWidth: 480, offsetHeight: 820 }} | ${{ x: 70, y: -60, width: 100, height: 100 }} | ${undefined} | ${4.78} | ${-70} | ${60}
Comment thread
tbouffard marked this conversation as resolved.
Dismissed
${FitType.HorizontalVertical} | ${{ offsetWidth: 480, offsetHeight: 820 }} | ${{ x: 70, y: -60, width: 100, height: 100 }} | ${30} | ${4.48} | ${-55} | ${75}
${FitType.Horizontal} | ${{ offsetWidth: 860, offsetHeight: 860 }} | ${{ x: 70, y: -60, width: 150, height: 300 }} | ${30} | ${5.52} | ${-55} | ${75}
${FitType.Vertical} | ${{ offsetWidth: 860, offsetHeight: 860 }} | ${{ x: 70, y: -60, width: 1000, height: 100 }} | ${30} | ${8} | ${-55} | ${75}
${FitType.Vertical} | ${{ offsetWidth: 860, offsetHeight: 860 }} | ${{ x: 70, y: -60, width: 1000, height: 100 }} | ${100} | ${7.58} | ${-20} | ${110}
`(
'Fit with $type (margin: $margin)',
({
type,
dimensions,
bounds,
margin,
expectedScale,
expectedTranslateX,
expectedTranslateY,
}: {
type: FitType;
dimensions: ContainerDimensions;
bounds: { x: number; y: number; width: number; height: number };
margin: number | undefined;
expectedScale: number;
expectedTranslateX: number;
expectedTranslateY: number;
}) => {
const { scaleAndTranslateSpy, setScaleSpy } = configureFitScenario(dimensions, bounds);

bpmnVisualization.navigation.fit({ type, margin });

expect(scaleAndTranslateSpy).toHaveBeenCalledExactlyOnceWith(expectedScale, expectedTranslateX, expectedTranslateY);
expect(setScaleSpy).not.toHaveBeenCalled();
expect(view.scale).toBe(expectedScale);
},
);
});
});

Expand Down