Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Graph control tests more #74884

Closed
wants to merge 7 commits into from
Closed
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
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ declare global {
namespace jest {
interface Matchers<R, T> {
toYieldEqualTo(expectedYield: T extends AsyncIterable<infer E> ? E : never): Promise<R>;
toYieldObjectEqualTo(expectedYield: unknown): Promise<R>;
Copy link
Contributor

Choose a reason for hiding this comment

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

❔ Not sure about making this even more complicated. What does it do that toYieldEqualTo doesn't? Would be helpful to read in a comment here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes

}
}
}
@@ -57,6 +58,70 @@ expect.extend({
}
}

// Use `pass` as set in the above loop (or initialized to `false`)
// See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils
const message = pass
? () =>
`${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\n` +
`Expected: not ${this.utils.printExpected(expected)}\n${
this.utils.stringify(expected) !== this.utils.stringify(received[received.length - 1]!)
? `Received: ${this.utils.printReceived(received[received.length - 1])}`
: ''
}`
: () =>
`${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\nCompared ${
received.length
} yields.\n\n${received
.map(
(next, index) =>
`yield ${index + 1}:\n\n${this.utils.printDiffOrStringify(
expected,
next,
'Expected',
'Received',
this.expand
)}`
)
.join(`\n\n`)}`;

return { message, pass };
},
/**
* A custom matcher that takes an async generator and compares each value it yields to an expected value.
* This uses the same equality logic as `toMatchObject`.
* If any yielded value equals the expected value, the matcher will pass.
* If the generator ends with none of the yielded values matching, it will fail.
*/
async toYieldObjectEqualTo<T>(
this: jest.MatcherContext,
receivedIterable: AsyncIterable<T>,
expected: T
): Promise<{ pass: boolean; message: () => string }> {
// Used in printing out the pass or fail message
const matcherName = 'toSometimesYieldEqualTo';
const options: jest.MatcherHintOptions = {
comment: 'deep equality with any yielded value',
isNot: this.isNot,
promise: this.promise,
};
// The last value received: Used in printing the message
const received: T[] = [];

// Set to true if the test passes.
let pass: boolean = false;

// Async iterate over the iterable
for await (const next of receivedIterable) {
// keep track of all received values. Used in pass and fail messages
received.push(next);
// Use deep equals to compare the value to the expected value
if ((this.equals(next, expected), [this.utils.iterableEquality, this.utils.subsetEquality])) {
// If the value is equal, break
pass = true;
break;
}
}

// Use `pass` as set in the above loop (or initialized to `false`)
// See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils
const message = pass
Original file line number Diff line number Diff line change
@@ -14,8 +14,9 @@ import { spyMiddlewareFactory } from '../spy_middleware_factory';
import { resolverMiddlewareFactory } from '../../store/middleware';
import { resolverReducer } from '../../store/reducer';
import { MockResolver } from './mock_resolver';
import { ResolverState, DataAccessLayer, SpyMiddleware } from '../../types';
import { ResolverState, DataAccessLayer, SpyMiddleware, SideEffectSimulator } from '../../types';
import { ResolverAction } from '../../store/actions';
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';

/**
* Test a Resolver instance using jest, enzyme, and a mock data layer.
@@ -43,6 +44,11 @@ export class Simulator {
* This is used by `debugActions`.
*/
private readonly spyMiddleware: SpyMiddleware;
/**
* Simulator which allows you to explicitly simulate resize events and trigger animation frames
*/
private readonly sideEffectSimulator: SideEffectSimulator;

constructor({
dataAccessLayer,
resolverComponentInstanceID,
@@ -87,11 +93,14 @@ export class Simulator {
// Used for `KibanaContextProvider`
const coreStart: CoreStart = coreMock.createStart();

this.sideEffectSimulator = sideEffectSimulatorFactory();

// Render Resolver via the `MockResolver` component, using `enzyme`.
this.wrapper = mount(
<MockResolver
resolverComponentInstanceID={this.resolverComponentInstanceID}
history={this.history}
sideEffectSimulator={this.sideEffectSimulator}
store={this.store}
coreStart={coreStart}
databaseDocumentID={databaseDocumentID}
@@ -173,6 +182,14 @@ export class Simulator {
return this.wrapper.debug();
}

/**
* This manually runs the animation frames tied to a configurable timestamp in the future
*/
public runAnimationFramesTimeFromNow(time: number = 0) {
this.sideEffectSimulator.controls.time = time;
this.sideEffectSimulator.controls.provideAnimationFrame();
}

/**
* Return an Enzyme ReactWrapper that includes the Related Events host button for a given process node
*
@@ -251,7 +268,63 @@ export class Simulator {
}

/**
* The icon element for the node detail title.
* Wrapper for the west panning button
*/
public westPanElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:west-button"]');
}

/**
* Wrapper for the south panning button
*/
public southPanElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:south-button"]');
}

/**
* Wrapper for the east panning button
*/
public eastPanElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:east-button"]');
}

/**
* Wrapper for the north panning button
*/
public northPanElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:north-button"]');
}

/**
* Wrapper for the center panning button
*/
public centerPanElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:center-button"]');
}

/**
* Wrapper for the zoom in button
*/
public zoomInElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:zoom-in"]');
}

/**
* Wrapper for the zoom out button
*/
public zoomOutElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:zoom-out"]');
}

/**
* Wrapper for the zoom slider
*/
public zoomSliderElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph-controls:zoom-slider"]');
}

/**
* The details of the selected node are shown in a description list. This returns the description elements of the description list.
*/
public nodeDetailViewTitleIcon(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:node-detail:title-icon"]');
@@ -297,7 +370,7 @@ export class Simulator {
public async resolveWrapper(
wrapperFactory: () => ReactWrapper,
predicate: (wrapper: ReactWrapper) => boolean = (wrapper) => wrapper.length > 0
): Promise<ReactWrapper | void> {
): Promise<ReactWrapper | undefined> {
for await (const wrapper of this.map(wrapperFactory)) {
if (predicate(wrapper)) {
return wrapper;
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@

/* eslint-disable react/display-name */

import React, { useMemo, useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { Router } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { Provider } from 'react-redux';
@@ -17,7 +17,6 @@ import { ResolverState, SideEffectSimulator, ResolverProps } from '../../types';
import { ResolverAction } from '../../store/actions';
import { ResolverWithoutProviders } from '../../view/resolver_without_providers';
import { SideEffectContext } from '../../view/side_effect_context';
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';

type MockResolverProps = {
/**
@@ -38,6 +37,10 @@ type MockResolverProps = {
history: React.ComponentProps<typeof Router>['history'];
/** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */
store: Store<ResolverState, ResolverAction>;
/**
* Pass the side effect simulator which handles animations and resizing. See `sideEffectSimulatorFactory`
*/
sideEffectSimulator: SideEffectSimulator;
/**
* All the props from `ResolverWithoutStore` can be passed. These aren't defaulted to anything (you might want to test what happens when they aren't present.)
*/
@@ -66,8 +69,6 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
setResolverElement(element);
}, []);

const simulator: SideEffectSimulator = useMemo(() => sideEffectSimulatorFactory(), []);

// Resize the Resolver element to match the passed in props. Resolver is size dependent.
useEffect(() => {
if (resolverElement) {
@@ -84,15 +85,15 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
return this;
},
};
simulator.controls.simulateElementResize(resolverElement, size);
props.sideEffectSimulator.controls.simulateElementResize(resolverElement, size);
}
}, [props.rasterWidth, props.rasterHeight, simulator.controls, resolverElement]);
}, [props.rasterWidth, props.rasterHeight, props.sideEffectSimulator.controls, resolverElement]);

return (
<I18nProvider>
<Router history={props.history}>
<KibanaContextProvider services={props.coreStart}>
<SideEffectContext.Provider value={simulator.mock}>
<SideEffectContext.Provider value={props.sideEffectSimulator.mock}>
<Provider store={props.store}>
<ResolverWithoutProviders
ref={resolverRef}
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { Simulator } from '../test_utilities/simulator';
import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children';
import { nudgeAnimationDuration } from '../store/camera/scaling_constants';
import '../test_utilities/extend_jest';

describe('graph controls: when relsover is loaded with an origin node', () => {
let simulator: Simulator;
let originEntityID: string;
let originNodeStyle: () => AsyncIterable<CSSStyleDeclaration | null>;
const resolverComponentInstanceID = 'graph-controls-test';

const originalPositionStyle: Readonly<{ left: string; top: string }> = {
left: '746.93132px',
top: '535.5792px',
};
const originalSizeStyle: Readonly<{ width: string; height: string }> = {
width: '360px',
height: '120px',
};

beforeEach(async () => {
const {
metadata: { databaseDocumentID, entityIDs },
dataAccessLayer,
} = noAncestorsTwoChildren();

simulator = new Simulator({
dataAccessLayer,
databaseDocumentID,
resolverComponentInstanceID,
});
originEntityID = entityIDs.origin;

originNodeStyle = () =>
simulator.map(() => {
const wrapper = simulator.processNodeElements({ entityID: originEntityID });
// `getDOMNode` can only be called on a wrapper of a single node: https://enzymejs.github.io/enzyme/docs/api/ReactWrapper/getDOMNode.html
if (wrapper.length === 1) {
return wrapper.getDOMNode<HTMLElement>().style;
}
return null;
});
});

it('should display all cardinal panning buttons and the center button', async () => {
await expect(
simulator.map(() => ({
westPanButton: simulator.westPanElement().length,
southPanButton: simulator.southPanElement().length,
eastPanButton: simulator.eastPanElement().length,
northPanButton: simulator.northPanElement().length,
centerButton: simulator.centerPanElement().length,
}))
).toYieldEqualTo({
westPanButton: 1,
southPanButton: 1,
eastPanButton: 1,
northPanButton: 1,
centerButton: 1,
});
});

it('should display the zoom buttons and slider', async () => {
await expect(
simulator.map(() => ({
zoomInButton: simulator.zoomInElement().length,
zoomOutButton: simulator.zoomOutElement().length,
zoomSlider: simulator.zoomSliderElement().length,
}))
).toYieldEqualTo({
zoomInButton: 1,
zoomOutButton: 1,
zoomSlider: 1,
});
});

it("should show the origin node in it's original position", async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo(originalPositionStyle);
});

describe('when the user clicks the west panning button', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.westPanElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node further left on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
left: '796.93132px',
top: '535.5792px',
});
});
});

describe('when the user clicks the south panning button', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.southPanElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node lower on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
left: '746.93132px',
top: '485.5792px',
});
});
});

describe('when the user clicks the east panning button', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.eastPanElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node further right on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
left: '696.93132px',
top: '535.5792px',
});
});
});

describe('when the user clicks the north panning button', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.northPanElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node higher on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
left: '746.93132px',
top: '585.5792px',
});
});
});

describe('when the user clicks the center panning button', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.northPanElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
(await simulator.resolveWrapper(() => simulator.centerPanElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it("should return the origin node to it's original position", async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo(originalPositionStyle);
});
});

it('should show the origin node as larger on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo(originalSizeStyle);
});

describe('when the zoom in button is clicked', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.zoomInElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node as larger on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
width: '427.7538290724795px',
height: '142.5846096908265px',
});
});
});

describe('when the zoom out button is clicked', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.zoomOutElement()))!.simulate('click');
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node as smaller on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
width: '303.0461709275204px',
height: '101.01539030917347px',
});
});
});

describe('when the slider is moved upwards', () => {
beforeEach(async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo(originalSizeStyle);

(await simulator.resolveWrapper(() => simulator.zoomSliderElement()))!.simulate('change', {
target: { value: 0.8 },
});
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node as large on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
width: '525.6000000000001px',
height: '175.20000000000005px',
});
});
});

describe('when the slider is moved downwards', () => {
beforeEach(async () => {
(await simulator.resolveWrapper(() => simulator.zoomSliderElement()))!.simulate('change', {
target: { value: 0.2 },
});
simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration);
});

it('should show the origin node as smaller on the screen', async () => {
await expect(originNodeStyle()).toYieldObjectEqualTo({
width: '201.60000000000002px',
height: '67.2px',
});
});
});
});
Original file line number Diff line number Diff line change
@@ -125,12 +125,13 @@ const GraphControlsComponent = React.memo(
className={className}
graphControlsBackground={colorMap.graphControlsBackground}
graphControlsIconColor={colorMap.graphControls}
data-test-subj="resolver:graph-controls"
>
<EuiPanel className="panning-controls" paddingSize="none" hasShadow>
<div className="panning-controls-top">
<button
className="north-button"
data-test-subj="north-button"
data-test-subj="resolver:graph-controls:north-button"
title="North"
onClick={handleNorth}
>
@@ -140,23 +141,23 @@ const GraphControlsComponent = React.memo(
<div className="panning-controls-middle">
<button
className="west-button"
data-test-subj="west-button"
data-test-subj="resolver:graph-controls:west-button"
title="West"
onClick={handleWest}
>
<EuiIcon type="arrowLeft" />
</button>
<button
className="center-button"
data-test-subj="center-button"
data-test-subj="resolver:graph-controls:center-button"
title="Center"
onClick={handleCenterClick}
>
<EuiIcon type="bullseye" />
</button>
<button
className="east-button"
data-test-subj="east-button"
data-test-subj="resolver:graph-controls:east-button"
title="East"
onClick={handleEast}
>
@@ -166,7 +167,7 @@ const GraphControlsComponent = React.memo(
<div className="panning-controls-bottom">
<button
className="south-button"
data-test-subj="south-button"
data-test-subj="resolver:graph-controls:south-button"
title="South"
onClick={handleSouth}
>
@@ -175,19 +176,27 @@ const GraphControlsComponent = React.memo(
</div>
</EuiPanel>
<EuiPanel className="zoom-controls" paddingSize="none" hasShadow>
<button title="Zoom In" data-test-subj="zoom-in" onClick={handleZoomInClick}>
<button
title="Zoom In"
data-test-subj="resolver:graph-controls:zoom-in"
onClick={handleZoomInClick}
>
<EuiIcon type="plusInCircle" />
</button>
<EuiRange
className="zoom-slider"
data-test-subj="zoom-slider"
data-test-subj="resolver:graph-controls:zoom-slider"
min={0}
max={1}
step={0.01}
value={scalingFactor}
onChange={handleZoomAmountChange}
/>
<button title="Zoom Out" data-test-subj="zoom-out" onClick={handleZoomOutClick}>
<button
title="Zoom Out"
data-test-subj="resolver:graph-controls:zoom-out"
onClick={handleZoomOutClick}
>
<EuiIcon type="minusInCircle" />
</button>
</EuiPanel>