Merge pull request #11938 from kialam/mesh-viz-unit-tests

Mesh viz unit tests
This commit is contained in:
kialam 2022-03-28 08:44:19 -07:00 committed by GitHub
commit ec5e677635
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1286 additions and 21 deletions

806
awx/ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,8 @@
"@lingui/loader": "^3.8.3",
"@lingui/macro": "^3.7.1",
"@nteract/mockument": "^1.0.4",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.4",
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
"babel-plugin-macros": "3.1.0",
"enzyme": "^3.10.0",

View File

@ -39,15 +39,23 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const history = useHistory();
const draw = () => {
let width;
let height;
setShowZoomControls(false);
const width = getWidth(SELECTOR);
const height = getHeight(SELECTOR);
try {
width = getWidth(SELECTOR);
height = getHeight(SELECTOR);
} catch (error) {
width = 700;
height = 600;
}
/* Add SVG */
d3.selectAll(`#chart > svg`).remove();
const svg = d3
.select('#chart')
.append('svg')
.attr('aria-label', 'mesh-svg')
.attr('class', 'mesh-svg')
.attr('width', `${width}px`)
.attr('height', `100%`);

View File

@ -0,0 +1,86 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import MeshGraph from './MeshGraph';
jest.mock('util/webWorker', () => {
return {
__esModule: true,
default: () => {
return {
postMessage: jest.fn().mockReturnValueOnce({
data: {
type: 'end',
links: [],
nodes: [
{
id: 1,
hostname: 'foo',
node_type: 'control',
node_state: 'healthy',
index: 0,
vx: -1,
vy: -5,
x: 400,
y: 300,
},
{
id: 2,
hostname: 'bar',
node_type: 'control',
node_state: 'healthy',
index: 1,
vx: -1,
vy: -5,
x: 500,
y: 200,
},
],
},
}),
onmessage: jest.fn(),
};
},
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('<MeshGraph />', () => {
test('renders correctly', async () => {
const mockData = {
data: {
nodes: [
{
id: 1,
hostname: 'foo',
node_type: 'control',
node_state: 'healthy',
},
{
id: 2,
hostname: 'bar',
node_type: 'control',
node_state: 'healthy',
},
],
links: [],
},
};
const mockZoomFn = jest.fn();
const mockSetZoomCtrFn = jest.fn();
render(
<MemoryRouter>
<MeshGraph
data={mockData}
showLegend={true}
zoom={mockZoomFn}
setShowZoomControls={mockSetZoomCtrFn}
/>
</MemoryRouter>
);
await waitFor(() => screen.getByLabelText('mesh-svg'));
expect(screen.getByLabelText('mesh-svg')).toBeVisible();
});
});

View File

@ -0,0 +1,135 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MeshAPI } from 'api';
import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TopologyView from './TopologyView';
jest.mock('../../api');
jest.mock('util/webWorker', () => {
return {
__esModule: true,
default: () => {
return {
postMessage: jest.fn().mockReturnValueOnce({
data: {
type: 'end',
links: [],
nodes: [
{
id: 1,
hostname: 'foo',
node_type: 'control',
node_state: 'healthy',
index: 0,
vx: -1,
vy: -5,
x: 400,
y: 300,
},
{
id: 2,
hostname: 'bar',
node_type: 'control',
node_state: 'healthy',
index: 1,
vx: -1,
vy: -5,
x: 500,
y: 200,
},
],
},
}),
onmessage: function handleWorkerEvent(event) {
switch (event.data.type) {
case 'tick':
return jest.fn(event.data);
case 'end':
return jest.fn(event.data);
default:
return false;
}
},
};
},
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('<TopologyView />', () => {
test('should render properly', async () => {
MeshAPI.read.mockResolvedValue({
data: {
nodes: [
{
id: 1,
hostname: 'foo',
node_type: 'control',
node_state: 'healthy',
},
{
id: 2,
hostname: 'bar',
node_type: 'control',
node_state: 'healthy',
},
],
links: [],
},
});
render(
<MemoryRouter>
<TopologyView />
</MemoryRouter>
);
await waitFor(() => screen.getByRole('heading', { level: 2 }));
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Topology View'
);
expect(screen.getByLabelText('mesh-svg')).toBeVisible();
});
test('should render with 0 nodes', async () => {
MeshAPI.read.mockResolvedValue({
data: {
nodes: [],
links: [],
},
});
render(
<MemoryRouter>
<TopologyView />
</MemoryRouter>
);
await waitFor(() => screen.getByRole('heading', { level: 2 }));
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Topology View'
);
expect(screen.getByLabelText('mesh-svg')).toBeVisible();
});
test('should handle API error', async () => {
MeshAPI.read.mockRejectedValueOnce(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/mesh_visualizer',
},
data: 'An error occurred',
status: 500,
},
})
);
render(
<MemoryRouter>
<TopologyView />
</MemoryRouter>
);
await waitFor(() => screen.getByRole('heading', { level: 2 }));
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Topology View'
);
expect(screen.getByText(/something went wrong/i)).toBeVisible();
});
});

View File

@ -8,34 +8,49 @@ import {
LABEL_TEXT_MAX_LENGTH,
} from '../constants';
export function getWidth(selector) {
return selector ? d3.select(selector).node().clientWidth : 700;
}
export function getHeight(selector) {
return selector ? d3.select(selector).node().clientHeight : 600;
}
export function renderStateColor(nodeState) {
return NODE_STATE_COLOR_KEY[nodeState];
return NODE_STATE_COLOR_KEY[nodeState] ? NODE_STATE_COLOR_KEY[nodeState] : '';
}
export function renderLabelText(nodeState, name) {
return `${NODE_STATE_HTML_ENTITY_KEY[nodeState]} ${truncateString(
name,
LABEL_TEXT_MAX_LENGTH
)}`;
if (typeof nodeState === 'string' && typeof name === 'string') {
return NODE_STATE_HTML_ENTITY_KEY[nodeState]
? `${NODE_STATE_HTML_ENTITY_KEY[nodeState]} ${truncateString(
name,
LABEL_TEXT_MAX_LENGTH
)}`
: ` ${truncateString(name, LABEL_TEXT_MAX_LENGTH)}`;
}
return ``;
}
export function renderNodeType(nodeType) {
return NODE_TYPE_SYMBOL_KEY[nodeType];
return NODE_TYPE_SYMBOL_KEY[nodeType] ? NODE_TYPE_SYMBOL_KEY[nodeType] : ``;
}
export function renderNodeIcon(selectedNode) {
if (selectedNode) {
const { node_type: nodeType } = selectedNode;
return NODE_TYPE_SYMBOL_KEY[nodeType];
return NODE_TYPE_SYMBOL_KEY[nodeType] ? NODE_TYPE_SYMBOL_KEY[nodeType] : ``;
}
return false;
}
export function redirectToDetailsPage(selectedNode, history) {
const { id: nodeId } = selectedNode;
const constructedURL = `/instances/${nodeId}/details`;
history.push(constructedURL);
if (selectedNode && history) {
const { id: nodeId } = selectedNode;
const constructedURL = `/instances/${nodeId}/details`;
history.push(constructedURL);
}
return false;
}
// DEBUG TOOLS
@ -79,11 +94,3 @@ export const generateRandomNodes = (n) => {
}
return generateRandomLinks(nodes, getRandomInt(1, n - 1));
};
export function getWidth(selector) {
return selector ? d3.select(selector).node().clientWidth : 700;
}
export function getHeight(selector) {
return selector !== null ? d3.select(selector).node().clientHeight : 600;
}

View File

@ -0,0 +1,84 @@
import {
renderStateColor,
renderLabelText,
renderNodeType,
renderNodeIcon,
redirectToDetailsPage,
getHeight,
getWidth,
} from './helpers';
describe('renderStateColor', () => {
test('returns correct node state color', () => {
expect(renderStateColor('healthy')).toBe('#3E8635');
});
test('returns empty string if state is not found', () => {
expect(renderStateColor('foo')).toBe('');
});
test('returns empty string if state is null', () => {
expect(renderStateColor(null)).toBe('');
});
test('returns empty string if state is zero/integer', () => {
expect(renderStateColor(0)).toBe('');
});
});
describe('renderNodeType', () => {
test('returns correct node type', () => {
expect(renderNodeType('control')).toBe('C');
});
test('returns empty string if state is not found', () => {
expect(renderNodeType('foo')).toBe('');
});
test('returns empty string if state is null', () => {
expect(renderNodeType(null)).toBe('');
});
test('returns empty string if state is zero/integer', () => {
expect(renderNodeType(0)).toBe('');
});
});
describe('renderNodeIcon', () => {
test('returns correct node icon', () => {
expect(renderNodeIcon({ node_type: 'control' })).toBe('C');
});
test('returns empty string if state is not found', () => {
expect(renderNodeIcon('foo')).toBe('');
});
test('returns empty string if state is null', () => {
expect(renderNodeIcon(null)).toBe(false);
});
test('returns empty string if state is zero/integer', () => {
expect(renderNodeIcon(0)).toBe(false);
});
});
describe('getWidth', () => {
test('returns 700 if selector is null', () => {
expect(getWidth(null)).toBe(700);
});
test('returns 700 if selector is zero/integer', () => {
expect(getWidth(0)).toBe(700);
});
});
describe('getHeight', () => {
test('returns 600 if selector is null', () => {
expect(getHeight(null)).toBe(600);
});
test('returns 600 if selector is zero/integer', () => {
expect(getHeight(0)).toBe(600);
});
});
describe('renderLabelText', () => {
test('returns label text correctly', () => {
expect(renderLabelText('error', 'foo')).toBe('! foo');
});
test('returns label text if invalid node state is passed', () => {
expect(renderLabelText('foo', 'bar')).toBe(' bar');
});
test('returns empty string if non string params are passed', () => {
expect(renderLabelText(0, null)).toBe('');
});
});
describe('redirectToDetailsPage', () => {
test('returns false if incorrect params are passed', () => {
expect(redirectToDetailsPage(null, 0)).toBe(false);
});
});

View File

@ -20,6 +20,9 @@ import { getWidth, getHeight } from './helpers';
*/
export default function useZoom(parentSelector, childSelector) {
if (typeof parentSelector !== 'string' && typeof childSelector !== 'string') {
return false;
}
const zoom = d3.zoom().on('zoom', ({ transform }) => {
d3.select(childSelector).attr('transform', transform);
});

View File

@ -0,0 +1,134 @@
import React from 'react';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import useZoom from './useZoom';
import Header from '../Header';
afterEach(() => {
jest.clearAllMocks();
});
describe('useZoom', () => {
test('hook returns a set of zoom functions', async () => {
render(
<svg className="parent" width="700" height="500">
<g className="child"></g>
</svg>
);
const hook = useZoom('.parent', '.child');
expect(hook).toMatchObject({
zoom: expect.any(Function),
zoomFit: expect.any(Function),
zoomIn: expect.any(Function),
zoomOut: expect.any(Function),
resetZoom: expect.any(Function),
});
});
test('user can zoom in', async () => {
const hook = useZoom('.parent', '.child');
jest.spyOn(hook, 'zoomIn').mockReturnValueOnce(jest.fn());
render(
<>
<Header
title={`Topology View`}
handleSwitchToggle={jest.fn()}
toggleState={true}
zoomIn={hook.zoomIn}
zoomOut={hook.zoomOut}
zoomFit={hook.zoomFit}
resetZoom={hook.resetZoom}
showZoomControls={true}
/>
<svg className="parent" width="700" height="500">
<g className="child"></g>
</svg>
</>
);
await waitFor(() => screen.getByRole('heading', { level: 2 }));
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Topology View'
);
fireEvent.click(screen.getByLabelText(/zoom in/i));
expect(hook.zoomIn).toBeCalledTimes(1);
});
test('user can zoom out', async () => {
const hook = useZoom('.parent', '.child');
jest.spyOn(hook, 'zoomOut').mockReturnValueOnce(jest.fn());
render(
<>
<Header
title={`Topology View`}
handleSwitchToggle={jest.fn()}
toggleState={true}
zoomIn={hook.zoomIn}
zoomOut={hook.zoomOut}
zoomFit={hook.zoomFit}
resetZoom={hook.resetZoom}
showZoomControls={true}
/>
<svg className="parent" width="700" height="500">
<g className="child"></g>
</svg>
</>
);
await waitFor(() => screen.getByRole('heading', { level: 2 }));
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Topology View'
);
fireEvent.click(screen.getByLabelText(/zoom out/i));
expect(hook.zoomOut).toBeCalledTimes(1);
});
test('user can zoom fit', async () => {
const hook = useZoom('.parent', '.child');
jest.spyOn(hook, 'zoomFit').mockReturnValueOnce(jest.fn());
render(
<>
<Header
title={`Topology View`}
handleSwitchToggle={jest.fn()}
toggleState={true}
zoomIn={hook.zoomIn}
zoomOut={hook.zoomOut}
zoomFit={hook.zoomFit}
resetZoom={hook.resetZoom}
showZoomControls={true}
/>
<svg className="parent" width="700" height="500">
<g className="child"></g>
</svg>
</>
);
await waitFor(() => screen.getByRole('heading', { level: 2 }));
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Topology View'
);
fireEvent.click(screen.getByLabelText(/fit to screen/i));
expect(hook.zoomFit).toBeCalledTimes(1);
});
test('user can reset zoom', async () => {
const hook = useZoom('.parent', '.child');
jest.spyOn(hook, 'resetZoom').mockReturnValueOnce(jest.fn());
render(
<>
<Header
title={`Topology View`}
handleSwitchToggle={jest.fn()}
toggleState={true}
zoomIn={hook.zoomIn}
zoomOut={hook.zoomOut}
zoomFit={hook.zoomFit}
resetZoom={hook.resetZoom}
showZoomControls={true}
/>
<svg className="parent" width="700" height="500">
<g className="child"></g>
</svg>
</>
);
await waitFor(() => screen.getByRole('heading', { level: 2 }));
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Topology View'
);
fireEvent.click(screen.getByLabelText(/reset zoom/i));
expect(hook.resetZoom).toBeCalledTimes(1);
});
});