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

Time series - tests #1198

Merged
Show file tree
Hide file tree
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
86 changes: 46 additions & 40 deletions src/components/experiment-tracking/time-series/time-series.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import * as d3 from 'd3';

import './time-series.css';

export const getSelectedOrderedData = (runData, selectedRuns) => {
return runData
.filter(([key, _]) => selectedRuns.includes(key))
.sort((a, b) => {
// We need to sort the selected data to match the order of selectedRuns.
// If we didn't, the highlighted runs would switch colors unnecessarily.
return selectedRuns.indexOf(a[0]) - selectedRuns.indexOf(b[0]);
})
.map(([key, value], i) => [new Date(formatTimestamp(key)), value]);
};

export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
const previouslySelectedRuns = usePrevious(selectedRuns);
const [showTooltip, setShowTooltip] = useState(tooltipDefaultProps);
Expand All @@ -17,6 +28,8 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
const { hoveredElementId, setHoveredElementId } =
useContext(HoverStateContext);

const defaultChartWidth = isNaN(chartWidth) ? 100 : chartWidth;

const margin = { top: 20, right: 0, bottom: 80, left: 40 };
const height = 150;
const chartBuffer = 0.02;
Expand Down Expand Up @@ -57,15 +70,6 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
.filter(([key, _]) => selectedRuns.includes(key))
.map(([key, value], i) => [new Date(formatTimestamp(key)), value]);

const selectedOrderedData = runData
.filter(([key, _]) => selectedRuns.includes(key))
.sort((a, b) => {
// We need to sort the selected data to match the order of selectedRuns.
// If we didn't, the highlighted runs would switch colors unnecessarily.
return selectedRuns.indexOf(a[0]) - selectedRuns.indexOf(b[0]);
})
.map(([key, value], i) => [new Date(formatTimestamp(key)), value]);

const yScales = {};

metricData.map(
Expand All @@ -82,7 +86,7 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
const xScale = d3
.scaleTime()
.domain([minDate, maxDate])
.range([0, chartWidth]);
.range([0, defaultChartWidth]);

if (rangeSelection) {
xScale.domain(rangeSelection);
Expand Down Expand Up @@ -189,7 +193,7 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
.brushX()
.extent([
[0, 0],
[chartWidth, height],
[defaultChartWidth, height],
])
.on('end', (e) => {
if (e.selection) {
Expand All @@ -209,12 +213,12 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
<svg
preserveAspectRatio="xMinYMin meet"
key={`time-series--${metricName}`}
width={chartWidth + margin.left + margin.right}
width={defaultChartWidth + margin.left + margin.right}
height={height + margin.top + margin.bottom}
>
<defs>
<clipPath id="clip">
<rect x={0} y={0} width={chartWidth} height={height} />
<rect x={0} y={0} width={defaultChartWidth} height={height} />
</clipPath>
</defs>

Expand All @@ -233,7 +237,7 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
<g
className="time-series__metric-axis-dual"
ref={getYAxis}
transform={`translate(${chartWidth},0)`}
transform={`translate(${defaultChartWidth},0)`}
/>

<text
Expand Down Expand Up @@ -279,7 +283,7 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
className="time-series__hovered-line"
x1={0}
y1={yScales[index](value)}
x2={chartWidth}
x2={defaultChartWidth}
y2={yScales[index](value)}
/>
<g className="time-series__ticks">
Expand Down Expand Up @@ -319,32 +323,34 @@ export const TimeSeries = ({ chartWidth, metricsData, selectedRuns }) => {
</g>

<g className="time-series__selected-group">
{selectedOrderedData.map(([key, value], index) => (
<React.Fragment key={key + value}>
<line
className={`time-series__run-line--selected-${index}`}
x1={xScale(key)}
y1={0}
x2={xScale(key)}
y2={height}
/>
<text
className="time-series__tick-text"
x={xScale(key)}
y={yScales[metricIndex](value[metricIndex])}
>
{value[metricIndex]?.toFixed(3)}
</text>
<path
className={`time-series__marker--selected-${index}`}
d={`${d3.symbol(selectedMarkerShape[index], 20)()}`}
transform={`translate(${xScale(key)},${yScales[
metricIndex
](value[metricIndex])})
{getSelectedOrderedData(runData, selectedRuns).map(
([key, value], index) => (
<React.Fragment key={key + value}>
<line
className={`time-series__run-line--selected-${index}`}
x1={xScale(key)}
y1={0}
x2={xScale(key)}
y2={height}
/>
<text
className="time-series__tick-text"
x={xScale(key)}
y={yScales[metricIndex](value[metricIndex])}
>
{value[metricIndex]?.toFixed(3)}
</text>
<path
className={`time-series__marker--selected-${index}`}
d={`${d3.symbol(selectedMarkerShape[index], 20)()}`}
transform={`translate(${xScale(key)},${yScales[
metricIndex
](value[metricIndex])})
rotate(${selectedMarkerRotate[index]})`}
/>
</React.Fragment>
))}
/>
</React.Fragment>
)
)}
</g>

<g className="time-series__trend-line">
Expand Down
210 changes: 210 additions & 0 deletions src/components/experiment-tracking/time-series/time-series.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React from 'react';
import { mount } from 'enzyme';

import { getSelectedOrderedData, TimeSeries } from './time-series';
import { HoverStateContext } from '../utils/hover-state-context';
import { formatTimestamp } from '../../../utils/date-utils';
import { data, selectedRuns, oneSelectedRun } from '../mock-data';

const metricsKeys = Object.keys(data.metrics);

const runKeys = Object.keys(data.runs);
const runData = Object.entries(data.runs);

const hoveredElement = 0;

describe('TimeSeries', () => {
const mockContextValue = {
hoveredElementId: runKeys[hoveredElement],
setHoveredElementId: jest.fn(),
};

const wrapper = mount(
<HoverStateContext.Provider value={mockContextValue}>
<TimeSeries metricsData={data} selectedRuns={selectedRuns}></TimeSeries>
</HoverStateContext.Provider>
);

it('renders without crashing', () => {
expect(wrapper.find('.time-series').length).toBe(1);
});

it('constructs an svg for each metric from the data', () => {
const svg = wrapper.find('.time-series').find('svg');
expect(svg.length).toBe(metricsKeys.length);
});

it('show tooltip onHover - runLine', () => {
wrapper
.find('.time-series__run-lines')
.find('line')
.at(hoveredElement)
.simulate('mouseover');

const tooltip = wrapper.find('.time-series').find('.tooltip');
expect(tooltip.hasClass('tooltip--show')).toBe(true);
});
});

describe('TimeSeries with multiple selected runs and hovered run', () => {
const mockContextValue = {
hoveredElementId: runKeys[hoveredElement],
setHoveredElementId: jest.fn(),
};

const wrapper = mount(
<HoverStateContext.Provider value={mockContextValue}>
<TimeSeries metricsData={data} selectedRuns={selectedRuns}></TimeSeries>
</HoverStateContext.Provider>
)
.find('.time-series')
.find('svg')
.find('g');

it('draw X, Y and dual axes for each metric chart', () => {
const xAxis = wrapper.find('.time-series__runs-axis');
expect(xAxis.length).toBe(metricsKeys.length);

const yAxis = wrapper.find('.time-series__metric-axis');
expect(yAxis.length).toBe(metricsKeys.length);

const dualAxis = wrapper.find('.time-series__metric-axis-dual');
expect(dualAxis.length).toBe(metricsKeys.length);
});

it('draw metricLine for each metric', () => {
const metricLine = wrapper.find('.time-series__metric-line');
expect(metricLine.length).toBe(metricsKeys.length);
});

it('draw runLines for each metric', () => {
const runLines = wrapper
.find('.time-series__run-lines')
.find('.time-series__run-line');
expect(runLines.length).toBe(runData.length * metricsKeys.length);
});

it('applies "time-series__run-line--hovered" class to the correct runLine on mouseover', () => {
const runLine = wrapper
.find('.time-series__run-lines')
.find('line')
.at(hoveredElement);

runData.forEach((_, index) => {
if (hoveredElement === index) {
expect(
runLine.at(index).hasClass('time-series__run-line--hovered')
).toBe(true);
}
});
});

it('selected group is returend in the correct order', () => {
const selectedGroupLine = wrapper
.find('.time-series__selected-group')
.find('line');

getSelectedOrderedData(runData, selectedRuns).forEach(([key, _], index) => {
const parsedSelectedDate = new Date(formatTimestamp(selectedRuns[index]));

if (parsedSelectedDate.getTime() === key.getTime()) {
expect(
selectedGroupLine
.at(index)
.hasClass(`time-series__run-line--selected-${index}`)
).toBe(true);
}
});
});

it('on double click reset to default zoom scale', () => {
const setRangeSelection = jest.fn();
const brushContainer = wrapper.find('.time-series__brush').at(0);
const onDbClick = jest.spyOn(React, 'useState');

onDbClick.mockImplementation((rangeSelection) => [
rangeSelection,
setRangeSelection,
]);
brushContainer.simulate('dblclick');

expect(setRangeSelection).toBeTruthy();
expect(brushContainer.length).toBe(1);
});
});

describe('TimeSeries with only one selected run and no hovered run', () => {
const mockContextValue = {
hoveredElementId: null,
setHoveredElementId: jest.fn(),
};

const wrapper = mount(
<HoverStateContext.Provider value={mockContextValue}>
<TimeSeries metricsData={data} selectedRuns={oneSelectedRun}></TimeSeries>
</HoverStateContext.Provider>
)
.find('.time-series')
.find('svg')
.find('g');

it('Class "time-series__run-line--blend" is not applied when there is only one selected run and no hovered element', () => {
const runLine = wrapper.find('.time-series__run-lines').find('line');

runData.forEach((_, index) => {
expect(runLine.at(index).hasClass('time-series__run-line--blend')).toBe(
false
);
});
});

it('Class "time-series__metric-line--blend" is not applied when there is only one selected run and no hovered element', () => {
const metricLine = wrapper.find('.time-series__metric-line');

metricsKeys.forEach((_, index) => {
expect(
metricLine.at(index).hasClass('time-series__metric-line--blend')
).toBe(false);
});
});
});

describe('TimeSeries with only one selected run and hovered run', () => {
const mockContextValue = {
hoveredElementId: runKeys[hoveredElement],
setHoveredElementId: jest.fn(),
};

const wrapper = mount(
<HoverStateContext.Provider value={mockContextValue}>
<TimeSeries metricsData={data} selectedRuns={oneSelectedRun}></TimeSeries>
</HoverStateContext.Provider>
)
.find('.time-series')
.find('svg')
.find('g');

it('Class "time-series__run-line--blend" is applied when there is only one selected run and hovered element', () => {
const runLine = wrapper.find('.time-series__run-lines').find('line');

runData.forEach((_, index) => {
if (hoveredElement === index) {
expect(runLine.at(index).hasClass('time-series__run-line--blend')).toBe(
true
);
}
});
});

it('Class "time-series__metric-line--blend" is applied when there is only one selected run and hovered element', () => {
const metricLine = wrapper.find('.time-series__metric-line');

metricsKeys.forEach((_, index) => {
if (hoveredElement === index) {
expect(
metricLine.at(index).hasClass('time-series__metric-line--blend')
).toBe(true);
}
});
});
});