Compare commits

..

2 Commits

Author SHA1 Message Date
Abhi Kumar
c266fa0087 fix: added fix for legend test 2026-02-12 17:08:25 +05:30
Abhi Kumar
54655c6ab2 test: added tests for utils + components 2026-02-12 16:53:06 +05:30
11 changed files with 1170 additions and 171 deletions

View File

@@ -0,0 +1,168 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import type { GraphVisibilityState } from '../../types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from '../legendVisibilityUtils';
describe('legendVisibilityUtils', () => {
const storageKey = LOCALSTORAGE.GRAPH_VISIBILITY_STATES;
beforeEach(() => {
localStorage.clear();
jest.spyOn(window.localStorage.__proto__, 'getItem');
jest.spyOn(window.localStorage.__proto__, 'setItem');
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
describe('getStoredSeriesVisibility', () => {
it('returns null when there is no stored visibility state', () => {
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
expect(localStorage.getItem).toHaveBeenCalledWith(storageKey);
});
it('returns null when widget has no stored dataIndex', () => {
const stored: GraphVisibilityState[] = [
{
name: 'widget-1',
dataIndex: [],
},
];
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
});
it('returns a Map of label to visibility when widget state exists', () => {
const stored: GraphVisibilityState[] = [
{
name: 'widget-1',
dataIndex: [
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
],
},
{
name: 'widget-2',
dataIndex: [{ label: 'Errors', show: true }],
},
];
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = getStoredSeriesVisibility('widget-1');
expect(result).not.toBeNull();
expect(result instanceof Map).toBe(true);
expect(result?.get('CPU')).toBe(true);
expect(result?.get('Memory')).toBe(false);
expect(result?.get('Errors')).toBeUndefined();
});
it('returns null on malformed JSON in localStorage', () => {
localStorage.setItem(storageKey, '{invalid-json');
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
});
it('returns null when widget id is not found', () => {
const stored: GraphVisibilityState[] = [
{
name: 'another-widget',
dataIndex: [{ label: 'CPU', show: true }],
},
];
localStorage.setItem(storageKey, JSON.stringify(stored));
const result = getStoredSeriesVisibility('widget-1');
expect(result).toBeNull();
});
});
describe('updateSeriesVisibilityToLocalStorage', () => {
it('creates new visibility state when none exists', () => {
const seriesVisibility = [
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
];
updateSeriesVisibilityToLocalStorage('widget-1', seriesVisibility);
const stored = getStoredSeriesVisibility('widget-1');
expect(stored).not.toBeNull();
expect(stored!.get('CPU')).toBe(true);
expect(stored!.get('Memory')).toBe(false);
});
it('adds a new widget entry when other widgets already exist', () => {
const existing: GraphVisibilityState[] = [
{
name: 'widget-existing',
dataIndex: [{ label: 'Errors', show: true }],
},
];
localStorage.setItem(storageKey, JSON.stringify(existing));
const newVisibility = [{ label: 'CPU', show: false }];
updateSeriesVisibilityToLocalStorage('widget-new', newVisibility);
const stored = getStoredSeriesVisibility('widget-new');
expect(stored).not.toBeNull();
expect(stored!.get('CPU')).toBe(false);
});
it('updates existing widget visibility when entry already exists', () => {
const initialVisibility: GraphVisibilityState[] = [
{
name: 'widget-1',
dataIndex: [
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
],
},
];
localStorage.setItem(storageKey, JSON.stringify(initialVisibility));
const updatedVisibility = [
{ label: 'CPU', show: false },
{ label: 'Memory', show: true },
];
updateSeriesVisibilityToLocalStorage('widget-1', updatedVisibility);
const stored = getStoredSeriesVisibility('widget-1');
expect(stored).not.toBeNull();
expect(stored!.get('CPU')).toBe(false);
expect(stored!.get('Memory')).toBe(true);
});
it('silently handles malformed existing JSON without throwing', () => {
localStorage.setItem(storageKey, '{invalid-json');
expect(() =>
updateSeriesVisibilityToLocalStorage('widget-1', [
{ label: 'CPU', show: true },
]),
).not.toThrow();
});
});
});

View File

@@ -178,9 +178,7 @@ export default function HostsListTable({
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
tableLayout="fixed"
rowKey={(record): string =>
(record as HostRowData & { key: string }).key ?? record.hostName
}
rowKey={(record): string => record.hostName}
onChange={handleTableChange}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),

View File

@@ -126,8 +126,7 @@
background: var(--bg-ink-500);
}
.ant-table-cell:has(.hostname-column-value),
.ant-table-cell:has(.hostname-cell-missing) {
.ant-table-cell:has(.hostname-column-value) {
background: var(--bg-ink-400);
}
@@ -140,23 +139,6 @@
letter-spacing: -0.07px;
}
.hostname-cell-missing {
display: flex;
align-items: center;
gap: 4px;
}
.hostname-cell-placeholder {
color: var(--Vanilla-400, #c0c1c3);
}
.hostname-cell-warning-icon {
display: inline-flex;
align-items: center;
margin-left: 4px;
cursor: help;
}
.status-cell {
.active-tag {
color: var(--bg-forest-500);
@@ -375,8 +357,7 @@
color: var(--bg-ink-500);
}
.ant-table-cell:has(.hostname-column-value),
.ant-table-cell:has(.hostname-cell-missing) {
.ant-table-cell:has(.hostname-column-value) {
background: var(--bg-vanilla-100);
}
@@ -384,10 +365,6 @@
color: var(--bg-ink-300);
}
.hostname-cell-placeholder {
color: var(--text-ink-300);
}
.ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.04);
}

View File

@@ -1,24 +1,13 @@
import { render, screen } from '@testing-library/react';
import { HostData, TimeSeries } from 'api/infraMonitoring/getHostLists';
import { render } from '@testing-library/react';
import {
formatDataForTable,
GetHostsQuickFiltersConfig,
HostnameCell,
} from '../utils';
import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils';
const PROGRESS_BAR_CLASS = '.progress-bar';
const emptyTimeSeries: TimeSeries = {
labels: {},
labelsArray: [],
values: [],
};
describe('InfraMonitoringHosts utils', () => {
describe('formatDataForTable', () => {
it('should format host data correctly', () => {
const mockData: HostData[] = [
const mockData = [
{
hostName: 'test-host',
active: true,
@@ -27,12 +16,8 @@ describe('InfraMonitoringHosts utils', () => {
wait: 0.05,
load15: 2.5,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
},
];
] as any;
const result = formatDataForTable(mockData);
@@ -61,7 +46,7 @@ describe('InfraMonitoringHosts utils', () => {
});
it('should handle inactive hosts', () => {
const mockData: HostData[] = [
const mockData = [
{
hostName: 'test-host',
active: false,
@@ -70,12 +55,12 @@ describe('InfraMonitoringHosts utils', () => {
wait: 0.02,
load15: 1.2,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
cpuTimeSeries: [],
memoryTimeSeries: [],
waitTimeSeries: [],
load15TimeSeries: [],
},
];
] as any;
const result = formatDataForTable(mockData);
@@ -83,65 +68,6 @@ describe('InfraMonitoringHosts utils', () => {
expect(inactiveTag.container.textContent).toBe('INACTIVE');
expect(inactiveTag.container.querySelector('.inactive')).toBeTruthy();
});
it('should set hostName to empty string when host has no hostname', () => {
const mockData: HostData[] = [
{
hostName: '',
active: true,
cpu: 0.5,
memory: 0.4,
wait: 0.01,
load15: 1.0,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
},
];
const result = formatDataForTable(mockData);
expect(result[0].hostName).toBe('');
expect(result[0].key).toBe('-0');
});
});
describe('HostnameCell', () => {
it('should render hostname when present (case A: no icon)', () => {
const { container } = render(<HostnameCell hostName="gke-prod-1" />);
expect(container.querySelector('.hostname-column-value')).toBeTruthy();
expect(container.textContent).toBe('gke-prod-1');
expect(container.querySelector('.hostname-cell-missing')).toBeFalsy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeFalsy();
});
it('should render placeholder and icon when hostName is empty (case B)', () => {
const { container } = render(<HostnameCell hostName="" />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
const iconWrapper = container.querySelector('.hostname-cell-warning-icon');
expect(iconWrapper).toBeTruthy();
expect(iconWrapper?.getAttribute('aria-label')).toBe(
'Missing host.name metadata',
);
expect(iconWrapper?.getAttribute('tabindex')).toBe('0');
// Tooltip with "Learn how to configure →" link is shown on hover/focus
});
it('should render placeholder and icon when hostName is whitespace only (case C)', () => {
const { container } = render(<HostnameCell hostName=" " />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
it('should render placeholder and icon when hostName is undefined (case D)', () => {
const { container } = render(<HostnameCell hostName={undefined} />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
});
describe('GetHostsQuickFiltersConfig', () => {

View File

@@ -1,7 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Progress, TabsProps, Tag, Tooltip, Typography } from 'antd';
import { Progress, TabsProps, Tag, Tooltip } from 'antd';
import { ColumnType } from 'antd/es/table';
import {
HostData,
@@ -14,7 +14,6 @@ import {
} from 'components/QuickFilters/types';
import TabLabel from 'components/TabLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TriangleAlert } from 'lucide-react';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -25,7 +24,6 @@ import HostsList from './HostsList';
import './InfraMonitoring.styles.scss';
export interface HostRowData {
key?: string;
hostName: string;
cpu: React.ReactNode;
memory: React.ReactNode;
@@ -34,59 +32,6 @@ export interface HostRowData {
active: React.ReactNode;
}
const HOSTNAME_DOCS_URL =
'https://signoz.io/docs/logs-management/guides/set-resource-attributes-for-logs/';
export function HostnameCell({
hostName,
}: {
hostName?: string | null;
}): React.ReactElement {
const isEmpty = !hostName || !hostName.trim();
if (!isEmpty) {
return <div className="hostname-column-value">{hostName}</div>;
}
return (
<div className="hostname-cell-missing">
<Typography.Text type="secondary" className="hostname-cell-placeholder">
-
</Typography.Text>
<Tooltip
title={
<div>
Missing host.name metadata.
<br />
<a
href={HOSTNAME_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
>
Learn how to configure
</a>
</div>
}
trigger={['hover', 'focus']}
>
<span
className="hostname-cell-warning-icon"
tabIndex={0}
role="img"
aria-label="Missing host.name metadata"
onClick={(e): void => e.stopPropagation()}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
>
<TriangleAlert size={14} color={Color.BG_CHERRY_500} />
</span>
</Tooltip>
</div>
);
}
export interface HostsListTableProps {
isLoading: boolean;
isError: boolean;
@@ -130,8 +75,8 @@ export const getHostsListColumns = (): ColumnType<HostRowData>[] => [
dataIndex: 'hostName',
key: 'hostName',
width: 250,
render: (value: string | undefined): React.ReactNode => (
<HostnameCell hostName={value ?? ''} />
render: (value: string): React.ReactNode => (
<div className="hostname-column-value">{value}</div>
),
},
{

View File

@@ -5,8 +5,8 @@ import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import { LegendPosition, LegendProps } from '../types';
import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
@@ -106,6 +106,7 @@ export default function Legend({
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
data-testid="legend-search-input"
className="legend-search-input"
/>
</div>

View File

@@ -0,0 +1,213 @@
import React from 'react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useLegendActions } from '../../hooks/useLegendActions';
import Legend from '../Legend/Legend';
import { LegendPosition } from '../types';
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
className,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
className?: string;
}): JSX.Element => (
<div data-testid="virtuoso-grid" className={className}>
{data.map((item, index) => (
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
{itemContent(index, item)}
</div>
))}
</div>
),
}));
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
jest.mock('lib/uPlotV2/hooks/useLegendActions');
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
typeof useLegendsSync
>;
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
typeof useLegendActions
>;
describe('Legend', () => {
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
label: 'A',
show: true,
color: '#ff0000',
},
1: {
seriesIndex: 1,
label: 'B',
show: false,
color: '#00ff00',
},
2: {
seriesIndex: 2,
label: 'C',
show: true,
color: '#0000ff',
},
};
let onLegendClick: jest.Mock;
let onLegendMouseMove: jest.Mock;
let onLegendMouseLeave: jest.Mock;
let onFocusSeries: jest.Mock;
beforeEach(() => {
onLegendClick = jest.fn();
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
focusedSeriesIndex: 1,
setFocusedSeriesIndex: jest.fn(),
});
mockUseLegendActions.mockReturnValue({
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onFocusSeries,
});
});
afterEach(() => {
jest.clearAllMocks();
});
const renderLegend = (position?: LegendPosition): RenderResult =>
render(
<Legend
position={position}
// config is not used directly in the component, it's consumed by the mocked hook
config={{} as any}
/>,
);
describe('layout and position', () => {
it('renders search input when position is RIGHT', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('renders the marker with the correct color', () => {
renderLegend(LegendPosition.RIGHT);
const legendMarker = document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
) as HTMLElement;
expect(legendMarker).toHaveStyle({
'border-color': '#ff0000',
});
});
it('does not render search input when position is BOTTOM (default)', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
});
it('renders all legend items in the grid by default', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('search behavior (RIGHT position)', () => {
it('filters legend items based on search query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'A');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows empty state when no legend items match the search query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'network');
expect(
screen.getByText(/No series found matching "network"/i),
).toBeInTheDocument();
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, ' ');
expect(
screen.queryByText(/No series found matching/i),
).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('legend actions', () => {
it('calls onLegendClick when a legend item is clicked', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(onLegendClick).toHaveBeenCalledTimes(1);
});
it('calls mouseMove when the mouse moves over a legend item', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const legendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
await user.hover(legendItem);
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
});
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const container = document.querySelector('.legend-container') as HTMLElement;
await user.hover(container);
await user.unhover(container);
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,318 @@
import { act, render } from '@testing-library/react';
import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import {
PlotContextProvider,
usePlotContext,
} from 'lib/uPlotV2/context/PlotContext';
import type uPlot from 'uplot';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
updateSeriesVisibilityToLocalStorage: jest.fn(),
}),
);
const mockUpdateSeriesVisibilityToLocalStorage = updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
interface MockSeries extends Partial<uPlot.Series> {
label?: string;
show?: boolean;
}
const createMockPlot = (series: MockSeries[] = []): uPlot =>
(({
series,
batch: jest.fn((fn: () => void) => fn()),
setSeries: jest.fn(),
} as unknown) as uPlot);
const getPlotContext = (): ReturnType<typeof usePlotContext> => {
let ctx: ReturnType<typeof usePlotContext> | null = null;
const Consumer = (): JSX.Element => {
// eslint-disable-next-line react-hooks/rules-of-hooks
ctx = usePlotContext();
return <div data-testid="consumer" />;
};
render(
<PlotContextProvider>
<Consumer />
</PlotContextProvider>,
);
if (!ctx) {
throw new Error('Context was not captured');
}
return ctx;
};
describe('PlotContext', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws when usePlotContext is used outside provider', () => {
const Consumer = (): JSX.Element => {
// eslint-disable-next-line react-hooks/rules-of-hooks
usePlotContext();
return <div />;
};
expect(() => render(<Consumer />)).toThrow(
'Should be used inside the context',
);
});
it('syncSeriesVisibilityToLocalStorage does nothing without plot or widgetId', () => {
const ctx = getPlotContext();
act(() => {
ctx.syncSeriesVisibilityToLocalStorage();
});
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('syncSeriesVisibilityToLocalStorage serializes series visibility to localStorage helper', () => {
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
const ctx = getPlotContext();
act(() => {
ctx.setPlotContextInitialState({
uPlotInstance: plot,
widgetId: 'widget-123',
shouldSaveSelectionPreference: true,
});
});
act(() => {
ctx.syncSeriesVisibilityToLocalStorage();
});
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
'widget-123',
[
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
],
);
});
describe('onToggleSeriesVisibility', () => {
it('does nothing when plot instance is not set', () => {
const ctx = getPlotContext();
act(() => {
ctx.onToggleSeriesVisibility(1);
});
// No errors and no calls to localStorage helper
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('highlights a single series and saves visibility when preferences are enabled', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
const ctx = getPlotContext();
act(() => {
ctx.setPlotContextInitialState({
uPlotInstance: plot,
widgetId: 'widget-visibility',
shouldSaveSelectionPreference: true,
});
});
act(() => {
ctx.onToggleSeriesVisibility(1);
});
const setSeries = (plot.setSeries as jest.Mock).mock.calls;
// index 0 is skipped, so we expect calls for 1 and 2
expect(setSeries).toEqual([
[1, { show: true }],
[2, { show: false }],
]);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
'widget-visibility',
[
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
],
);
});
it('resets visibility for all series when toggling the same index again', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
const ctx = getPlotContext();
act(() => {
ctx.setPlotContextInitialState({
uPlotInstance: plot,
widgetId: 'widget-reset',
shouldSaveSelectionPreference: true,
});
});
act(() => {
ctx.onToggleSeriesVisibility(1);
});
(plot.setSeries as jest.Mock).mockClear();
act(() => {
ctx.onToggleSeriesVisibility(1);
});
const setSeries = (plot.setSeries as jest.Mock).mock.calls;
// After reset, all non-zero series should be shown
expect(setSeries).toEqual([
[1, { show: true }],
[2, { show: true }],
]);
});
});
describe('onToggleSeriesOnOff', () => {
it('does nothing when plot instance is not set', () => {
const ctx = getPlotContext();
act(() => {
ctx.onToggleSeriesOnOff(1);
});
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('toggles series show flag and saves visibility when preferences are enabled', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
];
const plot = createMockPlot(series);
const ctx = getPlotContext();
act(() => {
ctx.setPlotContextInitialState({
uPlotInstance: plot,
widgetId: 'widget-toggle',
shouldSaveSelectionPreference: true,
});
});
act(() => {
ctx.onToggleSeriesOnOff(1);
});
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
'widget-toggle',
expect.any(Array),
);
});
it('does not toggle when target series does not exist', () => {
const series: MockSeries[] = [{ label: 'x-axis', show: true }];
const plot = createMockPlot(series);
const ctx = getPlotContext();
act(() => {
ctx.setPlotContextInitialState({
uPlotInstance: plot,
widgetId: 'widget-missing-series',
shouldSaveSelectionPreference: true,
});
});
act(() => {
ctx.onToggleSeriesOnOff(5);
});
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('does not persist visibility when preferences flag is disabled', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
];
const plot = createMockPlot(series);
const ctx = getPlotContext();
act(() => {
ctx.setPlotContextInitialState({
uPlotInstance: plot,
widgetId: 'widget-no-persist',
shouldSaveSelectionPreference: false,
});
});
act(() => {
ctx.onToggleSeriesOnOff(1);
});
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
});
describe('onFocusSeries', () => {
it('does nothing when plot instance is not set', () => {
const ctx = getPlotContext();
act(() => {
ctx.onFocusSeries(1);
});
});
it('sets focus on the given series index', () => {
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
]);
const ctx = getPlotContext();
act(() => {
ctx.setPlotContextInitialState({
uPlotInstance: plot,
widgetId: 'widget-focus',
shouldSaveSelectionPreference: false,
});
});
act(() => {
ctx.onFocusSeries(1);
});
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
});
});
});

View File

@@ -0,0 +1,218 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
jest.mock('lib/uPlotV2/context/PlotContext');
const mockUsePlotContext = usePlotContext as jest.MockedFunction<
typeof usePlotContext
>;
describe('useLegendActions', () => {
let onToggleSeriesVisibility: jest.Mock;
let onToggleSeriesOnOff: jest.Mock;
let onFocusSeriesPlot: jest.Mock;
let setPlotContextInitialState: jest.Mock;
let syncSeriesVisibilityToLocalStorage: jest.Mock;
let setFocusedSeriesIndexMock: jest.Mock;
let cancelAnimationFrameMock: jest.Mock;
let user: UserEvent;
beforeAll(() => {
user = userEvent.setup();
cancelAnimationFrameMock = jest.fn();
(global as any).cancelAnimationFrame = cancelAnimationFrameMock;
(global as any).requestAnimationFrame = (
cb: FrameRequestCallback,
): number => {
cb(0);
return 1;
};
});
beforeEach(() => {
onToggleSeriesVisibility = jest.fn();
onToggleSeriesOnOff = jest.fn();
onFocusSeriesPlot = jest.fn();
setPlotContextInitialState = jest.fn();
syncSeriesVisibilityToLocalStorage = jest.fn();
setFocusedSeriesIndexMock = jest.fn();
mockUsePlotContext.mockReturnValue({
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onFocusSeries: onFocusSeriesPlot,
setPlotContextInitialState,
syncSeriesVisibilityToLocalStorage,
});
cancelAnimationFrameMock.mockClear();
});
const TestLegendActionsComponent = ({
focusedSeriesIndex,
}: {
focusedSeriesIndex: number | null;
}): JSX.Element => {
const {
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex,
});
return React.createElement(
'div',
{
'data-testid': 'legend-container',
onClick: onLegendClick,
onMouseMove: onLegendMouseMove,
onMouseLeave: onLegendMouseLeave,
},
React.createElement(
'div',
{ 'data-testid': 'no-legend-target' },
'No legend',
),
React.createElement(
'div',
{ 'data-legend-item-id': '0' },
React.createElement('div', {
'data-testid': 'marker-0',
'data-is-legend-marker': 'true',
} as any),
React.createElement('div', { 'data-testid': 'label-0' }, 'Series 0'),
),
React.createElement(
'div',
{ 'data-legend-item-id': '1' },
React.createElement('div', {
'data-testid': 'marker-1',
'data-is-legend-marker': 'true',
} as any),
React.createElement('div', { 'data-testid': 'label-1' }, 'Series 1'),
),
);
};
describe('onLegendClick', () => {
it('toggles series visibility when clicking on legend label', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: null,
}),
);
await user.click(screen.getByTestId('label-0'));
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
});
it('toggles series on/off when clicking on marker', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: null,
}),
);
await user.click(screen.getByTestId('marker-0'));
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
it('does nothing when click target is not inside a legend item', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: null,
}),
);
await user.click(screen.getByTestId('no-legend-target'));
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
});
describe('onFocusSeries', () => {
it('schedules focus update and calls plot focus handler via mouse move', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: null,
}),
);
await user.hover(screen.getByTestId('label-0'));
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
});
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: null,
}),
);
await user.hover(screen.getByTestId('label-0'));
await user.hover(screen.getByTestId('label-1'));
expect(cancelAnimationFrameMock).toHaveBeenCalled();
});
});
describe('onLegendMouseMove', () => {
it('focuses new series when hovering over different legend item', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: 0,
}),
);
await user.hover(screen.getByTestId('label-1'));
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
});
it('does nothing when hovering over already focused series', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: 1,
}),
);
await user.hover(screen.getByTestId('label-1'));
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
});
});
describe('onLegendMouseLeave', () => {
it('cancels pending animation frame and clears focus state', async () => {
render(
React.createElement(TestLegendActionsComponent, {
focusedSeriesIndex: null,
}),
);
await user.hover(screen.getByTestId('label-0'));
await user.unhover(screen.getByTestId('legend-container'));
expect(cancelAnimationFrameMock).toHaveBeenCalled();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -0,0 +1,235 @@
import React, { useEffect } from 'react';
import { act, cleanup, render } from '@testing-library/react';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
describe('useLegendsSync', () => {
let requestAnimationFrameSpy: jest.Mock<
number,
[callback: FrameRequestCallback]
>;
let cancelAnimationFrameSpy: jest.Mock<void, [handle: number]>;
beforeAll(() => {
requestAnimationFrameSpy = jest.fn((cb: FrameRequestCallback): number => {
cb(0);
return 1;
});
cancelAnimationFrameSpy = jest.fn();
(global as any).requestAnimationFrame = requestAnimationFrameSpy;
(global as any).cancelAnimationFrame = cancelAnimationFrameSpy;
});
afterEach(() => {
jest.clearAllMocks();
cleanup();
});
afterAll(() => {
jest.resetAllMocks();
});
type HookResult = ReturnType<typeof useLegendsSync>;
let latestHookValue: HookResult;
const createMockConfig = (
legendItems: Record<number, LegendItem>,
): {
config: UPlotConfigBuilder;
invokeSetSeries: (
seriesIndex: number | null,
opts: { show?: boolean; focus?: boolean },
fireHook?: boolean,
) => void;
} => {
let setSeriesHandler:
| ((u: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const config = ({
getLegendItems: jest.fn(() => legendItems),
addHook: jest.fn(
(
hookName: string,
handler: (
u: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void,
) => {
if (hookName === 'setSeries') {
setSeriesHandler = handler;
}
return (): void => {
setSeriesHandler = null;
};
},
),
} as unknown) as UPlotConfigBuilder;
const invokeSetSeries = (
seriesIndex: number | null,
opts: { show?: boolean; focus?: boolean },
): void => {
if (setSeriesHandler) {
setSeriesHandler({} as uPlot, seriesIndex, { ...opts });
}
};
return { config, invokeSetSeries };
};
const TestComponent = ({
config,
subscribeToFocusChange,
}: {
config: UPlotConfigBuilder;
subscribeToFocusChange?: boolean;
}): JSX.Element => {
const value = useLegendsSync({ config, subscribeToFocusChange });
latestHookValue = value;
useEffect(() => {
latestHookValue = value;
}, [value]);
return React.createElement('div', { 'data-testid': 'hook-container' });
};
it('initializes legend items from config', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
2: { seriesIndex: 2, label: 'Memory', show: false, color: '#0f0' },
};
const { config } = createMockConfig(initialItems);
render(
React.createElement(TestComponent, {
config,
}),
);
expect(config.getLegendItems).toHaveBeenCalledTimes(1);
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
expect(latestHookValue.legendItemsMap).toEqual(initialItems);
});
it('updates focusedSeriesIndex when a series gains focus via setSeries by default', async () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
render(
React.createElement(TestComponent, {
config,
}),
);
expect(latestHookValue.focusedSeriesIndex).toBeNull();
await act(async () => {
invokeSetSeries(1, { focus: true });
});
expect(latestHookValue.focusedSeriesIndex).toBe(1);
});
it('does not update focusedSeriesIndex when subscribeToFocusChange is false', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
render(
React.createElement(TestComponent, {
config,
subscribeToFocusChange: false,
}),
);
invokeSetSeries(1, { focus: true });
expect(latestHookValue.focusedSeriesIndex).toBeNull();
});
it('updates legendItemsMap visibility when show changes for a series', async () => {
const initialItems: Record<number, LegendItem> = {
0: { seriesIndex: 0, label: 'x-axis', show: true, color: '#000' },
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
render(
React.createElement(TestComponent, {
config,
}),
);
// Toggle visibility of series 1
await act(async () => {
invokeSetSeries(1, { show: false });
});
expect(latestHookValue.legendItemsMap[1].show).toBe(false);
});
it('ignores visibility updates for unknown legend items or unchanged show values', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
render(
React.createElement(TestComponent, {
config,
}),
);
const before = latestHookValue;
// Unknown series index
invokeSetSeries(5, { show: false });
// Unchanged visibility for existing item
invokeSetSeries(1, { show: true });
const after = latestHookValue;
expect(after.legendItemsMap).toEqual(before.legendItemsMap);
});
it('cancels pending visibility RAF on unmount', () => {
const initialItems: Record<number, LegendItem> = {
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
};
const { config, invokeSetSeries } = createMockConfig(initialItems);
// Override RAF to not immediately invoke callback so we can assert cancellation
requestAnimationFrameSpy.mockImplementationOnce(() => 42);
const { unmount } = render(
React.createElement(TestComponent, {
config,
}),
);
invokeSetSeries(1, { show: false });
unmount();
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42);
});
});