Skip to content
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
276 changes: 276 additions & 0 deletions heatmapchart/src/components/HeatMapChart.exemplars.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import type * as ComponentsModule from '@perses-dev/components';
import { ChartsProvider, testChartsTheme, useChartsContext } from '@perses-dev/components';
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { ECharts } from 'echarts/core';
import { init } from 'echarts/core';
import type { ReactElement } from 'react';

import type { HeatMapExemplarDataItem } from '../utils';
import { getExemplarScatterSeries } from '../utils';
import type { HeatMapDataItem, HeatMapChartProps } from './HeatMapChart';
import { HeatMapChart } from './HeatMapChart';

vi.mock('@perses-dev/components', async (importOriginal) => ({
...(await importOriginal<typeof ComponentsModule>()),
ExemplarMetadataTooltip: ({ onUnpinClick }: { onUnpinClick?: () => void }): ReactElement => (
<div role="tooltip">
Exemplar tooltip<button onClick={onUnpinClick}>Unpin exemplar</button>
</div>
),
}));

const DATA: HeatMapDataItem[] = [
{ value: [0, 0.1, 0.2, 4], label: '4' },
{ value: [1, 0.1, 0.2, 6], label: '6' },
];

const CATEGORIES = [0, 60_000];

const EXEMPLARS: HeatMapExemplarDataItem[] = [
{
value: [0, 0.15],
exemplar: { labels: { trace_id: 'trace-1' }, value: 0.15, timestamp: 5_000 },
seriesLabels: { route: '/checkout' },
},
{
value: [1, 0.18],
exemplar: { labels: { trace_id: 'trace-2' }, value: 0.18, timestamp: 65_000 },
seriesLabels: { route: '/checkout' },
},
];

function PinElsewhereButton(): ReactElement {
const { setLastTooltipPinnedCoords } = useChartsContext();
return (
<button
onClick={() =>
setLastTooltipPinnedCoords({
page: { x: 1, y: 1 },
client: { x: 1, y: 1 },
plotCanvas: { x: 1, y: 1 },
target: null,
})
}
>
Pin in another chart
</button>
);
}

function chartElement(props: Partial<HeatMapChartProps> = {}, enablePinning = true): ReactElement {
return (
<ChartsProvider chartsTheme={testChartsTheme} enablePinning={enablePinning}>
<PinElsewhereButton />
<HeatMapChart
width={500}
height={300}
data={DATA}
xAxisCategories={CATEGORIES}
exemplars={EXEMPLARS}
{...props}
/>
</ChartsProvider>
);
}

function mockChart(): {
resize: ReturnType<typeof vi.fn>;
setOption: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
} {
const chart = {
resize: vi.fn(),
setOption: vi.fn(),
getWidth: (): number => 500,
getHeight: (): number => 300,
containPixel: (): boolean => false,
dispose: vi.fn(),
isDisposed: (): boolean => false,
on: vi.fn(),
off: vi.fn(),
dispatchAction: vi.fn(),
};
vi.mocked(init).mockReturnValue(chart as unknown as ECharts);
return chart;
}

describe('HeatMapChart exemplars', () => {
it('adds the exemplar scatter series on top of the heatmap series', () => {
const chart = mockChart();
render(chartElement());
const option = chart.setOption.mock.calls.at(-1)?.[0];
const heatmapSeries = option.series.find((series: { type: string }) => series.type === 'custom');
const marker = option.series.find((series: { type: string }) => series.type === 'scatter');
expect(heatmapSeries).toBeDefined();
expect(marker).toBeDefined();
// The exemplar scatter series renders after (above) the heatmap custom series.
expect(option.series.indexOf(marker)).toBeGreaterThan(option.series.indexOf(heatmapSeries));
// The visual map only colors the heatmap series.
expect(option.visualMap.seriesIndex).toBe(0);
});

it('does not add the exemplar scatter series without exemplars', () => {
const chart = mockChart();
render(chartElement({ exemplars: undefined }));
const option = chart.setOption.mock.calls.at(-1)?.[0];
expect(option.series.find((series: { type: string }) => series.type === 'scatter')).toBeUndefined();
});

it('shows the exemplar tooltip on hover, pins it on click and unpins from the tooltip', () => {
const chart = mockChart();
const { container } = render(chartElement());

const marker = getExemplarScatterSeries(EXEMPLARS);
const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1];
expect(mouseover).toBeTypeOf('function');
act(() =>
mouseover({
componentType: 'series',
seriesType: 'scatter',
seriesId: marker.id,
data: (marker.data as HeatMapExemplarDataItem[])[0],
}),
);
expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip');

// Clicking the canvas while hovering the marker pins the tooltip, and it stays
// visible after the mouse leaves the chart.
const wrapper = container.querySelector('button + div');
if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing');
const canvas = document.createElement('canvas');
wrapper.appendChild(canvas);
fireEvent.click(canvas);
fireEvent.mouseLeave(wrapper);
expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip');

// Unpin from the button inside the tooltip.
fireEvent.click(screen.getByRole('button', { name: 'Unpin exemplar' }));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

it('unpins the pinned exemplar tooltip when clicking elsewhere on the canvas', () => {
const chart = mockChart();
const { container } = render(chartElement());

const marker = getExemplarScatterSeries(EXEMPLARS);
const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1];
act(() =>
mouseover({
componentType: 'series',
seriesType: 'scatter',
seriesId: marker.id,
data: (marker.data as HeatMapExemplarDataItem[])[0],
}),
);
const wrapper = container.querySelector('button + div');
if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing');
const canvas = document.createElement('canvas');
wrapper.appendChild(canvas);
fireEvent.click(canvas); // pin
expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip');

const mouseout = chart.on.mock.calls.find(([event]) => event === 'mouseout')?.[1];
act(() => mouseout({ componentType: 'series', seriesType: 'scatter', seriesId: marker.id }));
expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip'); // pinned stays
fireEvent.click(canvas); // click elsewhere with no hover: unpins
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

it('does not pin the exemplar tooltip when pinning is disabled', () => {
const chart = mockChart();
const { container } = render(chartElement({}, false));

const marker = getExemplarScatterSeries(EXEMPLARS);
const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1];
act(() =>
mouseover({
componentType: 'series',
seriesType: 'scatter',
seriesId: marker.id,
data: (marker.data as HeatMapExemplarDataItem[])[0],
}),
);
const wrapper = container.querySelector('button + div');
if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing');
const canvas = document.createElement('canvas');
wrapper.appendChild(canvas);
fireEvent.click(canvas);
fireEvent.mouseLeave(wrapper);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

it('unpins the pinned exemplar tooltip when clicking its marker again after a query refresh', () => {
const chart = mockChart();
const { container, rerender } = render(chartElement());

const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1];
const hover = (exemplars: HeatMapExemplarDataItem[]): void => {
const marker = getExemplarScatterSeries(exemplars);
act(() =>
mouseover({
componentType: 'series',
seriesType: 'scatter',
seriesId: marker.id,
data: (marker.data as HeatMapExemplarDataItem[])[0],
}),
);
};
hover(EXEMPLARS);
const wrapper = container.querySelector('button + div');
if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing');
const canvas = document.createElement('canvas');
wrapper.appendChild(canvas);
fireEvent.click(canvas); // pin
expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip');

// A refresh returns the same exemplars as new objects.
const refreshed = structuredClone(EXEMPLARS);
rerender(chartElement({ exemplars: refreshed }));
hover(refreshed);
fireEvent.click(canvas); // click the same diamond: unpins
fireEvent.mouseLeave(wrapper);
const mouseout = chart.on.mock.calls.find(([event]) => event === 'mouseout')?.[1];
act(() => mouseout({ componentType: 'series', seriesType: 'scatter', seriesId: 'exemplar-heatmap' }));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});

it('unpins the pinned exemplar tooltip when a tooltip is pinned in another chart', () => {
const chart = mockChart();
const { container } = render(chartElement());

const marker = getExemplarScatterSeries(EXEMPLARS);
const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1];
act(() =>
mouseover({
componentType: 'series',
seriesType: 'scatter',
seriesId: marker.id,
data: (marker.data as HeatMapExemplarDataItem[])[0],
}),
);
const wrapper = container.querySelector('button + div');
if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing');
const canvas = document.createElement('canvas');
wrapper.appendChild(canvas);
fireEvent.click(canvas); // pin
fireEvent.mouseLeave(wrapper);
// Pinning here shares the coordinates without unpinning this chart's own tooltip.
expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip');

fireEvent.click(screen.getByRole('button', { name: 'Pin in another chart' }));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
Loading
Loading