diff --git a/locust/webui/src/components/LineChart/LineChart.types.ts b/locust/webui/src/components/LineChart/LineChart.types.ts index d5007cb669..58fb89d507 100644 --- a/locust/webui/src/components/LineChart/LineChart.types.ts +++ b/locust/webui/src/components/LineChart/LineChart.types.ts @@ -11,7 +11,7 @@ export interface ILineChart { title: string; lines: ILine[]; colors?: string[]; - chartValueFormatter?: (value: string | number | string[] | number[]) => string | number; + chartValueFormatter?: (value?: any) => string | number; splitAxis?: boolean; yAxisLabels?: string | [string, string]; } @@ -27,3 +27,10 @@ export interface ILineChartTimeAxis { export interface ILineChartMarkers { markers?: string[]; } + +export interface ILineChartTooltipFormatterParams { + axisValue: string; + color: string; + seriesName: string; + value: number; +} diff --git a/locust/webui/src/components/LineChart/LineChart.utils.ts b/locust/webui/src/components/LineChart/LineChart.utils.ts index 122203295c..eed3eac7b7 100644 --- a/locust/webui/src/components/LineChart/LineChart.utils.ts +++ b/locust/webui/src/components/LineChart/LineChart.utils.ts @@ -1,19 +1,28 @@ -import { ECharts, DefaultLabelFormatterCallbackParams, TooltipComponentOption } from 'echarts'; +import { + ECharts, + DefaultLabelFormatterCallbackParams, + LineSeriesOption, + YAXisComponentOption, +} from 'echarts'; +import { CHART_THEME } from 'components/LineChart/LineChart.constants'; +import { + ILineChartTimeAxis, + ILineChart, + ILineChartZoomEvent, + ILineChartTooltipFormatterParams, +} from 'components/LineChart/LineChart.types'; import { ICharts } from 'types/ui.types'; import { formatLocaleString, formatLocaleTime } from 'utils/date'; -import { CHART_THEME } from './LineChart.constants'; -import { ILineChartTimeAxis, ILineChart, ILineChartZoomEvent } from './LineChart.types'; - export const getSeriesData = ({ charts, lines, -}: Pick, 'charts' | 'lines'>) => +}: Pick, 'charts' | 'lines'>): LineSeriesOption[] => lines.map(({ key, name }) => ({ type: 'line', name, - data: charts[key], + data: charts[key] as LineSeriesOption['data'], })); const Y_AXIS_CONFIG = { @@ -24,7 +33,9 @@ const Y_AXIS_CONFIG = { const createYAxis = ({ splitAxis, yAxisLabels, -}: Pick, 'splitAxis' | 'yAxisLabels'>) => { +}: Pick, 'splitAxis' | 'yAxisLabels'>): + | YAXisComponentOption + | YAXisComponentOption[] => { if (splitAxis && (!yAxisLabels || Array.isArray(yAxisLabels))) { return Array(2) .fill(Y_AXIS_CONFIG) @@ -37,7 +48,7 @@ const createYAxis = ({ return { ...Y_AXIS_CONFIG, ...(yAxisLabels ? { name: yAxisLabels } : {}), - }; + } as YAXisComponentOption; }; export const createOptions = ({ @@ -49,11 +60,7 @@ export const createOptions = ({ splitAxis, yAxisLabels, }: ILineChart) => ({ - title: { - text: title, - x: 10, - y: 10, - }, + title: { text: title }, dataZoom: [ { type: 'inside', @@ -63,10 +70,11 @@ export const createOptions = ({ ], tooltip: { trigger: 'axis', - formatter: (params: TooltipComponentOption) => { + formatter: (params?: ILineChartTooltipFormatterParams[] | null) => { if (Array.isArray(params) && params.length > 0 && params.some(param => !!param.value)) { return params.reduce( - (tooltipText, { axisValue, color, seriesName, value }, index) => ` + (tooltipText, { axisValue, color, seriesName, value }, index) => + ` ${index === 0 ? formatLocaleString(axisValue) : ''} ${tooltipText}
@@ -89,7 +97,6 @@ export const createOptions = ({ }, yAxis: createYAxis({ splitAxis, yAxisLabels }), series: getSeriesData({ charts, lines }), - grid: { x: 60, y: 70, x2: 40, y2: 40 }, color: colors, toolbox: { feature: { diff --git a/locust/webui/src/components/LineChart/tests/LineChart.mocks.ts b/locust/webui/src/components/LineChart/tests/LineChart.mocks.ts new file mode 100644 index 0000000000..8bab8693da --- /dev/null +++ b/locust/webui/src/components/LineChart/tests/LineChart.mocks.ts @@ -0,0 +1,43 @@ +import { ICharts } from 'types/ui.types'; +import { formatLocaleTime } from 'utils/date'; + +export type MockChartType = Pick; + +export const mockChartLines = [ + { name: 'RPS', key: 'currentRps' as keyof MockChartType }, + { name: 'Failures/s', key: 'currentFailPerSec' as keyof MockChartType }, +]; + +export const mockCharts = { + currentRps: [3, 3.1, 3.27, 3.62, 4.19], + currentFailPerSec: [0, 0, 0, 0, 0], + time: [ + 'Tue, 06 Aug 2024 11:33:02 GMT', + 'Tue, 06 Aug 2024 11:33:08 GMT', + 'Tue, 06 Aug 2024 11:33:10 GMT', + 'Tue, 06 Aug 2024 11:33:12 GMT', + 'Tue, 06 Aug 2024 11:33:14 GMT', + ], +}; + +export const mockFormattedTimeAxis = mockCharts.time.map(formatLocaleTime); + +export const mockSeriesData = [ + { type: 'line', name: 'RPS', data: mockCharts.currentRps }, + { type: 'line', name: 'Failures/s', data: mockCharts.currentFailPerSec }, +]; + +export const mockTooltipParams = [ + { + axisValue: 'Tue, 06 Aug 2024 11:33:02 GMT', + color: '#ff0', + seriesName: 'RPS', + value: 1, + }, + { + axisValue: 'Tue, 06 Aug 2024 11:33:08 GMT', + color: '#0ff', + seriesName: 'User', + value: 10, + }, +]; diff --git a/locust/webui/src/components/LineChart/tests/LineChart.test.tsx b/locust/webui/src/components/LineChart/tests/LineChart.test.tsx new file mode 100644 index 0000000000..2aef3b3bd7 --- /dev/null +++ b/locust/webui/src/components/LineChart/tests/LineChart.test.tsx @@ -0,0 +1,28 @@ +import { describe, test, expect } from 'vitest'; + +import LineChart from 'components/LineChart/LineChart'; +import { mockChartLines } from 'components/LineChart/tests/LineChart.mocks'; +import { statsResponseTransformed } from 'test/mocks/statsRequest.mock'; +import { renderWithProvider } from 'test/testUtils'; +import { ICharts } from 'types/ui.types'; + +const mockChart = { + title: 'Total Requests per Second', + lines: mockChartLines, + colors: ['#00ca5a', '#ff6d6d'], +}; + +describe('LineChart', () => { + test('renders LineChart with charts and lines', () => { + const { container } = renderWithProvider( + + charts={statsResponseTransformed.charts} + colors={mockChart.colors} + lines={mockChart.lines} + title={mockChart.title} + />, + ); + + expect(container.querySelector('canvas')).toBeTruthy(); + }); +}); diff --git a/locust/webui/src/components/LineChart/tests/LineChartUtils.test.tsx b/locust/webui/src/components/LineChart/tests/LineChartUtils.test.tsx new file mode 100644 index 0000000000..2ada6e76bf --- /dev/null +++ b/locust/webui/src/components/LineChart/tests/LineChartUtils.test.tsx @@ -0,0 +1,258 @@ +import { DefaultLabelFormatterCallbackParams, ECharts } from 'echarts'; +import { describe, expect, test, vi } from 'vitest'; + +import { CHART_THEME } from 'components/LineChart/LineChart.constants'; +import { ILineChartTooltipFormatterParams } from 'components/LineChart/LineChart.types'; +import { + getSeriesData, + createOptions, + createMarkLine, + onChartZoom, +} from 'components/LineChart/LineChart.utils'; +import { + mockChartLines, + mockCharts, + MockChartType, + mockSeriesData, + mockTooltipParams, +} from 'components/LineChart/tests/LineChart.mocks'; +import { formatLocaleString, formatLocaleTime } from 'utils/date'; + +const removeWhitespace = (string: string) => string.replace(/\s+/g, ''); + +describe('getSeriesData', () => { + test('should adapt charts to series data', () => { + const options = getSeriesData>({ + charts: mockCharts, + lines: mockChartLines, + }); + + expect(options).toEqual(mockSeriesData); + }); +}); + +describe('createOptions', () => { + const createOptionsDefaultProps = { + charts: mockCharts, + title: 'Test Chart', + lines: mockChartLines, + colors: ['#fff'], + }; + + const defaultYAxis = { type: 'value', boundaryGap: [0, '5%'] }; + const yAxisLabels = ['RPS', 'Users'] as string[] as ['string', 'string']; + + test('should create chart options', () => { + const options = createOptions(createOptionsDefaultProps); + + expect(options.title.text).toBe('Test Chart'); + expect(options.xAxis.data).toEqual(mockCharts.time); + expect(options.yAxis).toEqual(defaultYAxis); + expect(options.series).toEqual(mockSeriesData); + expect(options.color).toEqual(['#fff']); + }); + + test('should not apply any default zoom', () => { + const options = createOptions(createOptionsDefaultProps); + + expect(options.dataZoom).toEqual([ + { + type: 'inside', + start: 0, + end: 100, + }, + ]); + }); + + test('xAxis should be formatted as expected', () => { + const options = createOptions(createOptionsDefaultProps); + + expect(options.xAxis.axisLabel.formatter(mockCharts.time[0])).toBe( + formatLocaleTime(mockCharts.time[0]), + ); + expect(options.xAxis.axisLabel.formatter('undefined')).toBe(''); + }); + + test('should format the tooltip as expected', () => { + const options = createOptions(createOptionsDefaultProps); + + expect(removeWhitespace(options.tooltip.formatter([mockTooltipParams[0]]))).toBe( + removeWhitespace(` + ${formatLocaleString(mockTooltipParams[0].axisValue)} + +
+ + ${mockTooltipParams[0].seriesName}: ${mockTooltipParams[0].value} + + `), + ); + expect(options.tooltip.trigger).toBe('axis'); + expect(options.tooltip.borderWidth).toBe(0); + }); + + test('should format the tooltip with multiple params', () => { + const options = createOptions(createOptionsDefaultProps); + + expect(removeWhitespace(options.tooltip.formatter(mockTooltipParams))).toBe( + removeWhitespace(` + ${formatLocaleString(mockTooltipParams[0].axisValue)} +
+ + ${mockTooltipParams[0].seriesName}: ${mockTooltipParams[0].value} + +
+ + ${mockTooltipParams[1].seriesName}: ${mockTooltipParams[1].value} + + `), + ); + }); + + test('should format the tooltip with no data when params are not an array, when length is zero, or when array contains no value prop', () => { + const options = createOptions(createOptionsDefaultProps); + + expect(options.tooltip.formatter(null)).toBe('No data'); + expect(options.tooltip.formatter(undefined)).toBe('No data'); + expect(options.tooltip.formatter([])).toBe('No data'); + expect(options.tooltip.formatter([{} as ILineChartTooltipFormatterParams])).toBe('No data'); + }); + + test('should not splitAxis by default', () => { + const options = createOptions(createOptionsDefaultProps); + + expect(options.yAxis).toEqual(defaultYAxis); + }); + + test('should splitAxis', () => { + const options = createOptions({ + ...createOptionsDefaultProps, + splitAxis: true, + }); + + expect(options.yAxis).toEqual([defaultYAxis, defaultYAxis]); + }); + + test('should ignore splitAxis if yAxisLabels is not an array', () => { + const yAxisLabel = 'RPS'; + const options = createOptions({ + ...createOptionsDefaultProps, + splitAxis: true, + yAxisLabels: yAxisLabel, + }); + const defaultYAxisWithName = { + ...defaultYAxis, + name: yAxisLabel, + }; + + expect(options.yAxis).toEqual(defaultYAxisWithName); + }); + + test('should splitAxis with yAxisLabels', () => { + const options = createOptions({ + ...createOptionsDefaultProps, + splitAxis: true, + yAxisLabels, + }); + + expect(options.yAxis).toEqual([ + { ...defaultYAxis, name: yAxisLabels[0] }, + { ...defaultYAxis, name: yAxisLabels[1] }, + ]); + }); +}); + +describe('createMarkLine', () => { + test('should create a mark line', () => { + const markChartsWithMarkers = { + ...mockCharts, + markers: [mockCharts.time[1]], + }; + + const options = createMarkLine(markChartsWithMarkers, false); + + expect(options.symbol).toBe('none'); + expect(options.data).toEqual([{ xAxis: markChartsWithMarkers.markers[0] }]); + expect(options.lineStyle.color).toBe(CHART_THEME.LIGHT.axisColor); + }); + + test('should create multiple mark lines', () => { + const markChartsWithMarkers = { + ...mockCharts, + markers: [mockCharts.time[1], mockCharts.time[3]], + }; + + const options = createMarkLine(markChartsWithMarkers, false); + + expect(options.data).toEqual([ + { xAxis: markChartsWithMarkers.markers[0] }, + { xAxis: markChartsWithMarkers.markers[1] }, + ]); + }); + + test('should format mark line label', () => { + const markChartsWithMarkers = { + ...mockCharts, + markers: [mockCharts.time[1]], + }; + const options = createMarkLine(markChartsWithMarkers, false); + + expect(options.label.formatter({ dataIndex: 0 } as DefaultLabelFormatterCallbackParams)).toBe( + 'Run #1', + ); + }); + + test('should use dark mode when isDarkMode', () => { + const markChartsWithMarkers = { + ...mockCharts, + markers: [mockCharts.time[1]], + }; + const options = createMarkLine(markChartsWithMarkers, true); + + expect(options.lineStyle.color).toBe(CHART_THEME.DARK.axisColor); + }); +}); + +describe('onChartZoom', () => { + test('should set dataZoom on chart zoom', () => { + const mockChart = { + setOption: vi.fn(), + } as unknown as ECharts; + + onChartZoom(mockChart)({ + batch: [{ start: 10, end: 90 }], + }); + + expect(mockChart.setOption).toHaveBeenCalledWith({ + dataZoom: [ + { type: 'inside', start: 10, end: 90 }, + { type: 'slider', show: true, start: 10, end: 90 }, + ], + }); + }); + + test('should reset dataZoom on zoom out', () => { + const mockChart = { + setOption: vi.fn(), + } as unknown as ECharts; + + onChartZoom(mockChart)({ + batch: [{ start: 50, end: 60 }], + }); + onChartZoom(mockChart)({ + batch: [{ start: 0, end: 100 }], + }); + + expect(mockChart.setOption).nthCalledWith(1, { + dataZoom: [ + { type: 'inside', start: 50, end: 60 }, + { type: 'slider', show: true, start: 50, end: 60 }, + ], + }); + expect(mockChart.setOption).nthCalledWith(2, { + dataZoom: [ + { type: 'inside', start: 0, end: 100 }, + { type: 'slider', show: false, start: 0, end: 100 }, + ], + }); + }); +}); diff --git a/locust/webui/src/utils/date.ts b/locust/webui/src/utils/date.ts index adc141ee31..97663a0868 100644 --- a/locust/webui/src/utils/date.ts +++ b/locust/webui/src/utils/date.ts @@ -1,5 +1,7 @@ +const isDate = (timestamp: string) => !isNaN(Date.parse(timestamp)); + export const formatLocaleString = (utcTimestamp: string) => - utcTimestamp ? new Date(utcTimestamp).toLocaleString() : ''; + utcTimestamp && isDate(utcTimestamp) ? new Date(utcTimestamp).toLocaleString() : ''; export const formatLocaleTime = (utcTimestamp: string) => - utcTimestamp ? new Date(utcTimestamp).toLocaleTimeString() : ''; + utcTimestamp && isDate(utcTimestamp) ? new Date(utcTimestamp).toLocaleTimeString() : '';