From fd146dde30f1f05ff5fc559c3c68635ec00369a6 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 22 Jan 2020 16:46:34 -0500 Subject: [PATCH] Adds unit test coverage to some of the workflow output components. Also adds support for hovering on workflow results links to see the edge type (success/fail/always). --- .../WorkflowOutput/WorkflowOutputGraph.jsx | 63 +++-- .../Job/WorkflowOutput/WorkflowOutputLink.jsx | 45 +++- .../WorkflowOutputLink.test.jsx | 42 ++++ .../Job/WorkflowOutput/WorkflowOutputNode.jsx | 4 +- .../WorkflowOutputNode.test.jsx | 102 ++++++++ .../WorkflowOutputStartNode.test.jsx | 21 ++ .../WorkflowOutputToolbar.test.jsx | 51 ++++ .../VisualizerGraph.jsx | 54 +++-- .../VisualizerLink.jsx | 8 +- awx/ui_next/src/util/workflow.jsx | 52 ++-- awx/ui_next/src/util/workflow.test.jsx | 225 ++++++++++++++++++ 11 files changed, 582 insertions(+), 85 deletions(-) create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx create mode 100644 awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx create mode 100644 awx/ui_next/src/util/workflow.test.jsx diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx index 04ad3219da..e053d7d346 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.jsx @@ -1,7 +1,10 @@ import React, { Fragment, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { arrayOf, bool, shape, func } from 'prop-types'; -import { calcZoomAndFit, getZoomTranslate } from '@util/workflow'; +import { + getScaleAndOffsetToFit, + getTranslatePointsForZoom, +} from '@util/workflow'; import { WorkflowOutputLink, WorkflowOutputNode, @@ -10,6 +13,7 @@ import { import { WorkflowHelp, WorkflowKey, + WorkflowLinkHelp, WorkflowNodeHelp, WorkflowTools, } from '@components/Workflow'; @@ -23,6 +27,7 @@ function WorkflowOutputGraph({ showKey, showTools, }) { + const [linkHelp, setLinkHelp] = useState(); const [nodeHelp, setNodeHelp] = useState(); const [zoomPercentage, setZoomPercentage] = useState(100); const svgRef = useRef(null); @@ -83,7 +88,17 @@ function WorkflowOutputGraph({ }; const handleZoomChange = newScale => { - const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale); + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const currentScaleAndOffset = d3.zoomTransform( + d3.select(svgRef.current).node() + ); + + const [translateX, translateY] = getTranslatePointsForZoom( + svgBoundingClientRect, + currentScaleAndOffset, + newScale + ); d3.select(svgRef.current).call( zoomRef.transform, @@ -93,9 +108,27 @@ function WorkflowOutputGraph({ }; const handleFitGraph = () => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current + const { k: currentScale } = d3.zoomTransform( + d3.select(svgRef.current).node() + ); + const gBoundingClientRect = d3 + .select(gRef.current) + .node() + .getBoundingClientRect(); + + const gBBoxDimensions = d3 + .select(gRef.current) + .node() + .getBBox(); + + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + + const [scaleToFit, yTranslate] = getScaleAndOffsetToFit( + gBoundingClientRect, + svgBoundingClientRect, + gBBoxDimensions, + currentScale ); d3.select(svgRef.current).call( @@ -118,19 +151,9 @@ function WorkflowOutputGraph({ // Attempt to zoom the graph to fit the available screen space useEffect(() => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current - ); - - d3.select(svgRef.current).call( - zoomRef.transform, - d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) - ); - - setZoomPercentage(scaleToFit * 100); + handleFitGraph(); // We only want this to run once (when the component mounts) - // Including zoomRef.transform in the deps array will cause this to + // Including handleFitGraph in the deps array will cause this to // run very frequently. // Discussion: https://github.com/facebook/create-react-app/issues/6880 // and https://github.com/facebook/react/issues/15865 amongst others @@ -139,9 +162,10 @@ function WorkflowOutputGraph({ return ( - {nodeHelp && ( + {(nodeHelp || linkHelp) && ( - + {nodeHelp && } + {linkHelp && } )} )), nodes.map(node => { diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx index fdfc91d6cd..1c29899404 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.jsx @@ -1,11 +1,32 @@ import React, { useEffect, useState } from 'react'; import { shape } from 'prop-types'; -import { generateLine, getLinePoints } from '@util/workflow'; +import { + generateLine, + getLinePoints, + getLinkOverlayPoints, +} from '@util/workflow'; -function WorkflowOutputLink({ link, nodePositions }) { +function WorkflowOutputLink({ link, nodePositions, onUpdateLinkHelp }) { + const [hovering, setHovering] = useState(false); const [pathD, setPathD] = useState(); const [pathStroke, setPathStroke] = useState('#CCCCCC'); + const handleLinkMouseEnter = () => { + const linkEl = document.getElementById( + `link-${link.source.id}-${link.target.id}` + ); + linkEl.parentNode.appendChild(linkEl); + setHovering(true); + }; + + const handleLinkMouseLeave = () => { + const linkEl = document.getElementById( + `link-${link.source.id}-${link.target.id}` + ); + linkEl.parentNode.prepend(linkEl); + setHovering(null); + }; + useEffect(() => { if (link.linkType === 'failure') { setPathStroke('#d9534f'); @@ -25,14 +46,22 @@ function WorkflowOutputLink({ link, nodePositions }) { return ( - + + onUpdateLinkHelp(link)} + onMouseLeave={() => onUpdateLinkHelp(null)} + opacity="0" + points={getLinkOverlayPoints(link, nodePositions)} /> ); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx new file mode 100644 index 0000000000..651efc1060 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputLink.test.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowOutputLink from './WorkflowOutputLink'; + +const link = { + source: { + id: 1, + }, + target: { + id: 2, + }, +}; + +const nodePositions = { + 1: { + width: 72, + height: 40, + x: 0, + y: 0, + }, + 2: { + width: 180, + height: 60, + x: 282, + y: 40, + }, +}; + +describe('WorkflowOutputLink', () => { + test('mounts successfully', () => { + const wrapper = mountWithContexts( + + {}} + /> + + ); + expect(wrapper).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx index 69ebd533aa..8b111bc80c 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -114,9 +114,7 @@ function WorkflowOutputNode({ : i18n._(t`DELETED`)}

- - {secondsToHHMMSS(node.job.elapsed)} - + {secondsToHHMMSS(node.job.elapsed)}
) : ( diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx new file mode 100644 index 0000000000..046ee99c73 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowOutputNode from './WorkflowOutputNode'; + +const nodeWithJT = { + id: 2, + job: { + elapsed: 7, + id: 9000, + name: 'Automation JT', + status: 'successful', + type: 'job', + }, + unifiedJobTemplate: { + id: 77, + name: 'Automation JT', + unified_job_type: 'job', + }, +}; + +const nodeWithoutJT = { + id: 2, + job: { + elapsed: 7, + id: 9000, + name: 'Automation JT', + status: 'successful', + type: 'job', + }, +}; + +const nodePositions = { + 1: { + width: 72, + height: 40, + x: 0, + y: 0, + }, + 2: { + width: 180, + height: 60, + x: 282, + y: 40, + }, +}; + +describe('WorkflowOutputNode', () => { + test('mounts successfully', () => { + const wrapper = mountWithContexts( + + {}} + mouseLeave={() => {}} + node={nodeWithJT} + nodePositions={nodePositions} + /> + + ); + expect(wrapper).toHaveLength(1); + }); + test('node contents displayed correctly when Job and Job Template exist', () => { + const wrapper = mountWithContexts( + + {}} + mouseLeave={() => {}} + node={nodeWithJT} + nodePositions={nodePositions} + /> + + ); + expect(wrapper.contains(

Automation JT

)).toEqual(true); + expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07'); + }); + test('node contents displayed correctly when Job Template deleted', () => { + const wrapper = mountWithContexts( + + {}} + mouseLeave={() => {}} + node={nodeWithoutJT} + nodePositions={nodePositions} + /> + + ); + expect(wrapper.contains(

DELETED

)).toEqual(true); + expect(wrapper.find('WorkflowOutputNode__Elapsed').text()).toBe('00:00:07'); + }); + test('node contents displayed correctly when Job deleted', () => { + const wrapper = mountWithContexts( + + {}} + mouseLeave={() => {}} + node={{ id: 2 }} + nodePositions={nodePositions} + /> + + ); + expect(wrapper.text()).toBe('DELETED'); + }); +}); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx new file mode 100644 index 0000000000..456c8aa19c --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputStartNode.test.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import WorkflowOutputStartNode from './WorkflowOutputStartNode'; + +const nodePositions = { + 1: { + x: 0, + y: 0, + }, +}; + +describe('WorkflowOutputStartNode', () => { + test('mounts successfully', () => { + const wrapper = mount( + + + + ); + expect(wrapper).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx new file mode 100644 index 0000000000..02c2fecba8 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowOutputToolbar from './WorkflowOutputToolbar'; + +const job = { + id: 1, + status: 'successful', +}; + +describe('WorkflowOutputToolbar', () => { + test('mounts successfully', () => { + const wrapper = mountWithContexts( + {}} + onToolsToggle={() => {}} + toolsShown={false} + /> + ); + expect(wrapper).toHaveLength(1); + }); + + test('shows correct number of nodes', () => { + const nodes = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + isDeleted: true, + }, + ]; + const wrapper = mountWithContexts( + {}} + onToolsToggle={() => {}} + toolsShown={false} + /> + ); + // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored + expect(wrapper.find('Badge').text()).toBe('1'); + }); +}); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index 58081edad1..a37b022e14 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -5,9 +5,9 @@ import styled from 'styled-components'; import { arrayOf, bool, func, shape } from 'prop-types'; import * as d3 from 'd3'; import { - calcZoomAndFit, + getScaleAndOffsetToFit, constants as wfConstants, - getZoomTranslate, + getTranslatePointsForZoom, } from '@util/workflow'; import { WorkflowHelp, @@ -161,7 +161,17 @@ function VisualizerGraph({ }; const handleZoomChange = newScale => { - const [translateX, translateY] = getZoomTranslate(svgRef.current, newScale); + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + const currentScaleAndOffset = d3.zoomTransform( + d3.select(svgRef.current).node() + ); + + const [translateX, translateY] = getTranslatePointsForZoom( + svgBoundingClientRect, + currentScaleAndOffset, + newScale + ); d3.select(svgRef.current).call( zoomRef.transform, @@ -171,9 +181,27 @@ function VisualizerGraph({ }; const handleFitGraph = () => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current + const { k: currentScale } = d3.zoomTransform( + d3.select(svgRef.current).node() + ); + const gBoundingClientRect = d3 + .select(gRef.current) + .node() + .getBoundingClientRect(); + + const gBBoxDimensions = d3 + .select(gRef.current) + .node() + .getBBox(); + + const svgElement = document.getElementById('workflow-svg'); + const svgBoundingClientRect = svgElement.getBoundingClientRect(); + + const [scaleToFit, yTranslate] = getScaleAndOffsetToFit( + gBoundingClientRect, + svgBoundingClientRect, + gBBoxDimensions, + currentScale ); d3.select(svgRef.current).call( @@ -196,19 +224,9 @@ function VisualizerGraph({ // Attempt to zoom the graph to fit the available screen space useEffect(() => { - const [scaleToFit, yTranslate] = calcZoomAndFit( - gRef.current, - svgRef.current - ); - - d3.select(svgRef.current).call( - zoomRef.transform, - d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit) - ); - - setZoomPercentage(scaleToFit * 100); + handleFitGraph(); // We only want this to run once (when the component mounts) - // Including zoomRef.transform in the deps array will cause this to + // Including handleFitGraph in the deps array will cause this to // run very frequently. // Discussion: https://github.com/facebook/create-react-app/issues/6880 // and https://github.com/facebook/react/issues/15865 amongst others diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx index c3e3ea7fa4..4a9fd852f1 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerLink.jsx @@ -116,7 +116,6 @@ function VisualizerLink({ return ( - + onUpdateLinkHelp(link)} onMouseLeave={() => onUpdateLinkHelp(null)} diff --git a/awx/ui_next/src/util/workflow.jsx b/awx/ui_next/src/util/workflow.jsx index c37dca118f..6ed9bf903f 100644 --- a/awx/ui_next/src/util/workflow.jsx +++ b/awx/ui_next/src/util/workflow.jsx @@ -11,24 +11,15 @@ export const constants = { rootH: 40, }; -export function calcZoomAndFit(gRef, svgRef) { - const { k: currentScale } = d3.zoomTransform(d3.select(svgRef).node()); - const gBoundingClientRect = d3 - .select(gRef) - .node() - .getBoundingClientRect(); - +export function getScaleAndOffsetToFit( + gBoundingClientRect, + svgBoundingClientRect, + gBBoxDimensions, + currentScale +) { gBoundingClientRect.height /= currentScale; gBoundingClientRect.width /= currentScale; - const gBBoxDimensions = d3 - .select(gRef) - .node() - .getBBox(); - - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); - // For some reason the root width needs to be added? gBoundingClientRect.width += constants.rootW; @@ -96,19 +87,19 @@ export function getLinePoints(link, nodePositions) { ]; } -export function getLinkOverlayPoints(d, nodePositions) { +export function getLinkOverlayPoints(link, nodePositions) { const sourceX = - nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1; + nodePositions[link.source.id].x + nodePositions[link.source.id].width + 1; let sourceY = - normalizeY(nodePositions, nodePositions[d.source.id].y) + - nodePositions[d.source.id].height / 2; - const targetX = nodePositions[d.target.id].x - 1; + normalizeY(nodePositions, nodePositions[link.source.id].y) + + nodePositions[link.source.id].height / 2; + const targetX = nodePositions[link.target.id].x - 1; const targetY = - normalizeY(nodePositions, nodePositions[d.target.id].y) + - nodePositions[d.target.id].height / 2; + normalizeY(nodePositions, nodePositions[link.target.id].y) + + nodePositions[link.target.id].height / 2; // There's something off with the math on the root node... - if (d.source.id === 1) { + if (link.source.id === 1) { sourceY += 10; } const slope = (targetY - sourceY) / (targetX - sourceX); @@ -177,18 +168,19 @@ export function layoutGraph(nodes, links) { return g; } -export function getZoomTranslate(svgRef, newScale) { - const svgElement = document.getElementById('workflow-svg'); - const svgBoundingClientRect = svgElement.getBoundingClientRect(); - const current = d3.zoomTransform(d3.select(svgRef).node()); - const origScale = current.k; +export function getTranslatePointsForZoom( + svgBoundingClientRect, + currentScaleAndOffset, + newScale +) { + const origScale = currentScaleAndOffset.k; const unscaledOffsetX = - (current.x + + (currentScaleAndOffset.x + (svgBoundingClientRect.width * origScale - svgBoundingClientRect.width) / 2) / origScale; const unscaledOffsetY = - (current.y + + (currentScaleAndOffset.y + (svgBoundingClientRect.height * origScale - svgBoundingClientRect.height) / 2) / diff --git a/awx/ui_next/src/util/workflow.test.jsx b/awx/ui_next/src/util/workflow.test.jsx new file mode 100644 index 0000000000..0cc1ad05a3 --- /dev/null +++ b/awx/ui_next/src/util/workflow.test.jsx @@ -0,0 +1,225 @@ +import { + getScaleAndOffsetToFit, + generateLine, + getLinePoints, + getLinkOverlayPoints, + layoutGraph, + getTranslatePointsForZoom, +} from './workflow'; + +describe('getScaleAndOffsetToFit', () => { + const gBoundingClientRect = { + x: 36, + y: 11, + width: 798, + height: 160, + top: 11, + right: 834, + bottom: 171, + left: 36, + }; + const svgBoundingClientRect = { + x: 0, + y: 56, + width: 1680, + height: 455, + top: 56, + right: 1680, + bottom: 511, + left: 0, + }; + const gBBoxDimensions = { + x: 36, + y: -45, + width: 726, + height: 160, + }; + const currentScale = 1; + test('returns correct scale and y-offset for zooming the graph to best fit the available space', () => { + expect( + getScaleAndOffsetToFit( + gBoundingClientRect, + svgBoundingClientRect, + gBBoxDimensions, + currentScale + ) + ).toEqual([1.931, 159.91499999999996]); + }); +}); + +describe('generateLine', () => { + test('returns correct svg path string', () => { + expect( + generateLine([ + { + x: 0, + y: 0, + }, + { + x: 10, + y: 10, + }, + ]) + ).toEqual('M0,0L10,10'); + expect( + generateLine([ + { + x: 900, + y: 44, + }, + { + x: 5000, + y: 359, + }, + ]) + ).toEqual('M900,44L5000,359'); + }); +}); + +describe('getLinePoints', () => { + const link = { + source: { + id: 1, + }, + target: { + id: 2, + }, + }; + const nodePositions = { + 1: { + width: 72, + height: 40, + x: 36, + y: 130, + }, + 2: { + width: 180, + height: 60, + x: 282, + y: 40, + }, + }; + test('returns the correct endpoints of the line', () => { + expect(getLinePoints(link, nodePositions)).toEqual([ + { x: 109, y: 30 }, + { x: 281, y: -60 }, + ]); + }); +}); + +describe('getLinkOverlayPoints', () => { + const link = { + source: { + id: 1, + }, + target: { + id: 2, + }, + }; + const nodePositions = { + 1: { + width: 72, + height: 40, + x: 36, + y: 130, + }, + 2: { + width: 180, + height: 60, + x: 282, + y: 40, + }, + }; + test('returns the four points of the polygon that will act as the overlay for the link', () => { + expect(getLinkOverlayPoints(link, nodePositions)).toEqual( + '281,-50.970992003685446 109,39.02900799631457 109,20.97099200368546 281,-69.02900799631456' + ); + }); +}); + +describe('layoutGraph', () => { + const nodes = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + { + id: 4, + }, + ]; + const links = [ + { + source: { + id: 1, + }, + target: { + id: 2, + }, + }, + { + source: { + id: 1, + }, + target: { + id: 4, + }, + }, + { + source: { + id: 2, + }, + target: { + id: 3, + }, + }, + { + source: { + id: 4, + }, + target: { + id: 3, + }, + }, + ]; + test('returns the correct dimensions and positions for the nodes', () => { + expect(layoutGraph(nodes, links)._nodes).toEqual({ + 1: { height: 40, label: '', width: 72, x: 36, y: 75 }, + 2: { height: 60, label: '', width: 180, x: 282, y: 30 }, + 3: { height: 60, label: '', width: 180, x: 582, y: 75 }, + 4: { height: 60, label: '', width: 180, x: 282, y: 120 }, + }); + }); +}); + +describe('getTranslatePointsForZoom', () => { + const svgBoundingClientRect = { + x: 0, + y: 56, + width: 1680, + height: 455, + top: 56, + right: 1680, + bottom: 511, + left: 0, + }; + const currentScaleAndOffset = { + k: 2, + x: 0, + y: 167.5, + }; + const newScale = 1.9; + test('returns the correct translation point', () => { + expect( + getTranslatePointsForZoom( + svgBoundingClientRect, + currentScaleAndOffset, + newScale + ) + ).toEqual([42, 170.5]); + }); +});