diff --git a/packages/manager/.changeset/pr-11464-upcoming-features-1735566884863.md b/packages/manager/.changeset/pr-11464-upcoming-features-1735566884863.md new file mode 100644 index 00000000000..ff755a9d6f2 --- /dev/null +++ b/packages/manager/.changeset/pr-11464-upcoming-features-1735566884863.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Exhaustive unit tests for CloudPulse widgets ([#11464](https://github.com/linode/manager/pull/11464)) diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts new file mode 100644 index 00000000000..0d1985230b3 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -0,0 +1,237 @@ +import { formatPercentage } from 'src/utilities/statMetrics'; + +import { + generateGraphData, + generateMaxUnit, + getDimensionName, + getLabelName, + mapResourceIdToName, +} from './CloudPulseWidgetUtils'; + +import type { CloudPulseMetricsResponse } from '@linode/api-v4'; +import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; + +describe('generateMaxUnit method', () => { + it('returns the appropriate unit for mixed data values', () => { + const legendRowsData: MetricsDisplayRow[] = [ + { + data: { average: 1500, last: 1600, length: 3, max: 2000, total: 4500 }, + format: formatPercentage, + legendColor: '#000', + legendTitle: 'linode-1', + }, + { + data: { + average: 2000000, + last: 2100000, + length: 3, + max: 2500000, + total: 6000000, + }, + format: formatPercentage, + legendColor: '#000', + legendTitle: 'linode-2', + }, + ]; + + const result = generateMaxUnit(legendRowsData, 'Bytes'); + expect(result).toBe('MB'); + }); + + it('returns correct unit for empty array', () => { + const legendRowsData: MetricsDisplayRow[] = []; + const result = generateMaxUnit(legendRowsData, 'Bytes'); + expect(result).toBe('B'); + }); + + it('returns correct unit when max is zero', () => { + const legendRowsData: MetricsDisplayRow[] = [ + { + data: { average: 0, last: 0, length: 3, max: 0, total: 0 }, + format: formatPercentage, + legendColor: '#000', + legendTitle: 'linode-1', + }, + ]; + const result = generateMaxUnit(legendRowsData, 'Bytes'); + expect(result).toBe('B'); + }); +}); + +describe('getLabelName method', () => { + const baseProps = { + flags: { + aclpResourceTypeMap: [ + { dimensionKey: 'resource_id', serviceType: 'linode' }, + ], + }, + label: 'CPU Usage', + metric: { resource_id: '123' }, + resources: [{ id: '123', label: 'linode-1' }], + serviceType: 'linode', + unit: '%', + }; + + it('returns resource label when all data is valid', () => { + const result = getLabelName(baseProps); + expect(result).toBe('linode-1'); + }); + + it('returns resource_id when resource is not found in resources array', () => { + const props = { + ...baseProps, + metric: { resource_id: '999' }, + }; + const result = getLabelName(props); + expect(result).toBe('999'); + }); + + it('returns empty string when resource_id is empty', () => { + const props = { + ...baseProps, + metric: { resource_id: '' }, + }; + const result = getLabelName(props); + expect(result).toBe(''); + }); +}); + +it('test generateGraphData with metrics data', () => { + const mockMetricsResponse: CloudPulseMetricsResponse = { + data: { + result: [ + { + metric: { resource_id: '1' }, + values: [[1234567890, '50']], + }, + ], + result_type: 'matrix', + }, + isPartial: false, + stats: { + series_fetched: 1, + }, + status: 'success', + }; + + const result = generateGraphData({ + flags: { + aclpResourceTypeMap: [ + { dimensionKey: 'resource_id', serviceType: 'linode' }, + ], + }, + label: 'Graph', + metricsList: mockMetricsResponse, + resources: [{ id: '1', label: 'linode-1' }], + serviceType: 'linode', + status: 'success', + unit: '%', + }); + + expect(result.areas[0].dataKey).toBe('linode-1'); + expect(result.dimensions).toEqual([ + { + 'linode-1': 50, + timestamp: 1234567890000, + }, + ]); + + expect(result.legendRowsData[0].data).toEqual({ + average: 50, + last: 50, + length: 1, + max: 50, + total: 50, + }); + expect(result.legendRowsData[0].format).toBeDefined(); + expect(result.legendRowsData[0].legendTitle).toBe('linode-1'); + expect(result.unit).toBe('%'); +}); + +describe('getDimensionName method', () => { + const baseProps = { + flag: { dimensionKey: 'resource_id', serviceType: 'linode' }, + metric: { resource_id: '123' }, + resources: [{ id: '123', label: 'linode-1' }], + }; + + it('returns resource label when all data is valid', () => { + const result = getDimensionName(baseProps); + expect(result).toBe('linode-1'); + }); + + it('returns resource_id when flag is undefined', () => { + const props = { + ...baseProps, + flag: undefined, + }; + const result = getDimensionName(props); + expect(result).toBe('123'); + }); + + it('returns empty string when metric is empty', () => { + const props = { + ...baseProps, + metric: {}, + }; + const result = getDimensionName(props); + expect(result).toBe(''); + }); + + it('returns value directly when key does not match dimensionKey', () => { + const props = { + ...baseProps, + metric: { other_key: '456' }, + }; + const result = getDimensionName(props); + expect(result).toBe('456'); + }); + + it('joins multiple metric values with underscore', () => { + const props = { + ...baseProps, + metric: { other_key: 'test', resource_id: '123' }, + }; + const result = getDimensionName(props); + expect(result).toBe('test_linode-1'); + }); + + it('handles empty metric values by filtering them out', () => { + const props = { + ...baseProps, + metric: { other_key: '', resource_id: '123' }, + }; + const result = getDimensionName(props); + expect(result).toBe('linode-1'); + }); + + it('returns resource_id directly when resources array is empty', () => { + const props = { + ...baseProps, + resources: [], + }; + const result = getDimensionName(props); + expect(result).toBe('123'); + }); + + it('returns empty string when both resource_id is empty and flag is undefined', () => { + const props = { + ...baseProps, + flag: undefined, + metric: { resource_id: '' }, + }; + const result = getDimensionName(props); + expect(result).toBe(''); + }); +}); + +it('test mapResourceIdToName method', () => { + const resources = [ + { id: '123', label: 'linode-1' }, + { id: '456', label: 'inode-2' }, + ]; + + expect(mapResourceIdToName('123', resources)).toBe('linode-1'); + expect(mapResourceIdToName('999', resources)).toBe('999'); + expect(mapResourceIdToName(undefined, resources)).toBe(''); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index f4066808b3d..ae540c93146 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -267,7 +267,7 @@ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { * @param unit base unit of the values * @returns maximum possible rolled up unit based on the unit */ -const generateMaxUnit = (legendRowsData: MetricsDisplayRow[], unit: string) => { +export const generateMaxUnit = (legendRowsData: MetricsDisplayRow[], unit: string) => { const maxValue = Math.max( 0, ...legendRowsData?.map((row) => row?.data.max ?? 0) @@ -307,7 +307,7 @@ export const getCloudPulseMetricRequest = ( * * @returns generated label name for graph dimension */ -const getLabelName = (props: LabelNameOptionsProps): string => { +export const getLabelName = (props: LabelNameOptionsProps): string => { const { flags, label, metric, resources, serviceType, unit } = props; // aggregated metric, where metric keys will be 0 if (!Object.keys(metric).length) { diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx new file mode 100644 index 00000000000..716883ed945 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx @@ -0,0 +1,196 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { + cloudPulseMetricsResponseDataFactory, + widgetFactory, +} from 'src/factories'; +import * as CloudPulseWidgetUtils from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils'; +import { formatPercentage } from 'src/utilities/statMetrics'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseWidget } from './CloudPulseWidget'; + +import type { CloudPulseWidgetProperties } from './CloudPulseWidget'; + +const props: CloudPulseWidgetProperties = { + additionalFilters: [], + ariaLabel: 'CPU Utilization', + availableMetrics: { + available_aggregate_functions: ['min', 'max', 'avg'], + dimensions: [], + label: 'CPU utilization', + metric: 'system_cpu_utilization_percent', + metric_type: 'gauge', + scrape_interval: '2m', + unit: 'percent', + }, + duration: { unit: 'min', value: 30 }, + isJweTokenFetching: false, + resourceIds: ['1', '2'], + resources: [ + { + id: '1', + label: 'test-1', + }, + { + id: '2', + label: 'test-2', + }, + ], + savePref: true, + serviceType: 'linode', + unit: '%', + widget: widgetFactory.build({ + label: 'CPU Utilization', + }), +}; + +const queryMocks = vi.hoisted(() => ({ + useCloudPulseMetricsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/metrics', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/metrics'); + return { + ...actual, + useCloudPulseMetricsQuery: queryMocks.useCloudPulseMetricsQuery, + }; +}); + +queryMocks.useCloudPulseMetricsQuery.mockReturnValue({ + data: cloudPulseMetricsResponseDataFactory.build(), + isError: false, + isLoading: false, + status: 'success', +}); + +const mockMetrics = { + average: 5.5, + last: 7.75, + length: 3, + max: 10, + total: 40, +}; +const graphData = { + areas: [ + { + color: '#1CB35C', + dataKey: 'test-1', + }, + ], + dimensions: [], + legendRowsData: [ + { + data: mockMetrics, + format: formatPercentage, + handleLegendClick: vi.fn(), + legendColor: 'blue', + legendTitle: 'Test', + }, + ], + unit: '%', +}; +vi.spyOn(CloudPulseWidgetUtils, 'generateGraphData').mockReturnValue(graphData); + +class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +const mockUpdatePreferences = vi.fn(); +vi.mock('../Utils/UserPreference', () => ({ + useAclpPreference: () => ({ + updateWidgetPreference: mockUpdatePreferences, + }), +})); + +describe('Cloud pulse widgets', () => { + window.ResizeObserver = ResizeObserver; + + it('should render widget with all required components', () => { + const { container, getByTestId, getByText } = renderWithTheme( + + ); + + // Verify widget title and unit + expect(getByText('CPU Utilization (%)')).toBeInTheDocument(); + + // Verify interval select + expect(getByTestId('Data aggregation interval')).toBeInTheDocument(); + + // Verify aggregate function select + expect(getByTestId('Aggregation function')).toBeInTheDocument(); + + // Verify zoom icon + expect(getByTestId('zoom-in')).toBeInTheDocument(); + + // Verify graph component + expect( + container.querySelector('.recharts-responsive-container') + ).toBeInTheDocument(); + }); + + it('should show error state when metrics fetch fails', () => { + queryMocks.useCloudPulseMetricsQuery.mockReturnValue({ + data: undefined, + error: [{ reason: 'test reason' }], + isLoading: false, + status: 'error', + }); + + const { getByText } = renderWithTheme(); + expect(getByText('test reason')).toBeInTheDocument(); + }); + + it('should update preferences for zoom toggle', async () => { + const { getByTestId } = renderWithTheme(); + const zoomButton = getByTestId('zoom-in'); + await userEvent.click(zoomButton); + expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', { + size: 6, + }); + }); + + it('should update preferences for aggregation function select', async () => { + const { getByRole } = renderWithTheme(); + + await userEvent.click( + getByRole('combobox', { name: 'Select an Aggregate Function' }) + ); + + await userEvent.click(getByRole('option', { name: 'Max' })); + + expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', { + aggregateFunction: 'max', + }); + + expect(queryMocks.useCloudPulseMetricsQuery).toHaveBeenCalledWith( + 'linode', + expect.objectContaining({ + aggregate_function: 'max', + }), + expect.any(Object) + ); + }); + + it('should update preferences for interval select', async () => { + const { getByRole } = renderWithTheme(); + await userEvent.click( + getByRole('combobox', { name: 'Select an Interval' }) + ); + await userEvent.click(getByRole('option', { name: '5 min' })); + + expect(mockUpdatePreferences).toHaveBeenCalledWith('CPU Utilization', { + timeGranularity: { unit: 'min', value: 5 }, + }); + expect(queryMocks.useCloudPulseMetricsQuery).toHaveBeenCalledWith( + 'linode', + expect.objectContaining({ + time_granularity: { unit: 'min', value: 5 } + }), + expect.any(Object) + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx new file mode 100644 index 00000000000..0e5cd7ab381 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseLineGraph } from './CloudPulseLineGraph'; + +const mockData = { + areas: [ + { + color: 'theme.color.green', + dataKey: 'system_cpu_utilization_percent', + }, + ], + ariaLabel: 'CPU Utilization', + data: [ + { system_cpu_utilization_percent: 10, timestamp: 1672531200000 }, + { system_cpu_utilization_percent: 20, timestamp: 1672617600000 }, + { system_cpu_utilization_percent: 30, timestamp: 1672704000000 }, + ], + error: undefined, + loading: false, + timezone: 'UTC', + unit: '%', + xAxis: { + tickFormat: 'HH:mm', + tickGap: 50, + }, +}; + +class ResizeObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +describe('CloudPulseLineGraph', () => { + window.ResizeObserver = ResizeObserver; + + it('should render AreaChart when data is provided', () => { + const { container, getByRole } = renderWithTheme( + + ); + const table = getByRole('table'); + + expect( + container.querySelector('.recharts-responsive-container') + ).toBeInTheDocument(); + expect(table).toHaveAttribute( + 'summary', + 'This table contains the data for the CPU Utilization (system_cpu_utilization_percent)' + ); + }); + + it('should show error state', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Test error')).toBeInTheDocument(); + }); + + it('should show no data message when data array is empty', () => { + const emptyData = { + ...mockData, + data: [], + }; + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('No data to display')).toBeInTheDocument(); + }); +});