diff --git a/apps/web/src/api/buster_rest/chats/requests.test.ts b/apps/web/src/api/buster_rest/chats/requests.test.ts index 7612661bf..d63c08478 100644 --- a/apps/web/src/api/buster_rest/chats/requests.test.ts +++ b/apps/web/src/api/buster_rest/chats/requests.test.ts @@ -41,8 +41,8 @@ describe('Chat API Requests', () => { ]; // Setup mock response - (mainApiV2.get as any).mockResolvedValueOnce({ - data: { data: mockChats } + (mainApiV2.get as any).mockResolvedValueOnce({ + data: { data: mockChats }, }); // Import the function we want to test diff --git a/apps/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useTooltipOptions.ts/barAndLineTooltipHelper.test.ts b/apps/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useTooltipOptions.ts/barAndLineTooltipHelper.test.ts index 239725fbc..d697afdce 100644 --- a/apps/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useTooltipOptions.ts/barAndLineTooltipHelper.test.ts +++ b/apps/web/src/components/ui/charts/BusterChartJS/hooks/useOptions/useTooltipOptions.ts/barAndLineTooltipHelper.test.ts @@ -1,161 +1,133 @@ -import type { ColumnLabelFormat } from '@buster/server-shared/metrics'; -import type { - BarControllerDatasetOptions, - Chart, - ChartDatasetProperties, - TooltipItem, -} from 'chart.js'; -import { describe, expect, it } from 'vitest'; +import type { ChartConfigProps } from '@buster/server-shared/metrics'; +import type { Chart, ChartTypeRegistry, TooltipItem } from 'chart.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { formatLabel } from '@/lib/columnFormatter'; import { barAndLineTooltipHelper } from './barAndLineTooltipHelper'; +import { getPercentage } from './helpers'; -type MockDataset = Partial< - ChartDatasetProperties<'bar', number[]> & BarControllerDatasetOptions -> & { - type: 'bar'; - label: string; - data: number[]; - tooltipData: { key: string; value: number }[][]; - yAxisKey: string; - xAxisKeys: string[]; -}; +// Mock dependencies +vi.mock('@/lib/columnFormatter', () => ({ + formatLabel: vi.fn(), +})); + +vi.mock('./helpers', () => ({ + getPercentage: vi.fn(), +})); + +const mockFormatLabel = vi.mocked(formatLabel); +const mockGetPercentage = vi.mocked(getPercentage); describe('barAndLineTooltipHelper', () => { - // Mock data setup - const mockChart = { - data: { - datasets: [ - { - data: [100, 200, 300], - label: 'Dataset 1', - type: 'bar' as const, - hidden: false, - isTrendline: false, - }, - { - data: [150, 250, 350], - label: 'Dataset 2', - type: 'bar' as const, - hidden: false, - isTrendline: false, - }, - ], - }, - $totalizer: { - stackTotals: [250, 450, 650], - seriesTotals: [600, 750], - }, - scales: { - x: { - getPixelForValue: () => 0, - }, - y: { - getPixelForValue: () => 0, - }, - }, - } as unknown as Chart; + const mockChart = {} as Chart; + const mockColumnLabelFormats = {} as NonNullable; + const mockKeyToUsePercentage = ['percentage_metric']; - const mockColumnLabelFormats: Record = { - value: { - columnType: 'number', - style: 'number', - } as ColumnLabelFormat, - percentage: { - columnType: 'number', - style: 'percent', - } as ColumnLabelFormat, - label: { - columnType: 'text', - style: 'string', - } as ColumnLabelFormat, - }; - - const createMockDataset = (overrides = {}): MockDataset => ({ - type: 'bar', - label: 'Test Dataset', - backgroundColor: '#FF0000', - borderColor: '#FF0000', - yAxisKey: 'value', - data: [250, 300, 350], - xAxisKeys: ['x'], - barPercentage: 0.9, - categoryPercentage: 0.8, - tooltipData: [ - [ - { key: 'value', value: 250 }, - { key: 'percentage', value: 25 }, - ], - ], - hidden: false, - ...overrides, + beforeEach(() => { + vi.clearAllMocks(); + mockFormatLabel.mockReturnValue('formatted_value'); + mockGetPercentage.mockReturnValue('25%'); }); - const createMockDataPoint = (overrides = {}): TooltipItem<'bar'> => ({ - datasetIndex: 0, - dataIndex: 0, - dataset: createMockDataset(), - parsed: { x: 0, y: 250 }, - raw: 250, - formattedValue: '250', - label: 'Test Dataset', - chart: mockChart, - element: {} as any, - ...overrides, - }); + it('should format single dataset tooltip correctly when hasMultipleShownDatasets is false', () => { + // Arrange + const mockDataPoints: TooltipItem[] = [ + { + dataset: { + label: 'Revenue Dataset', + backgroundColor: '#ff0000', + tooltipData: [[{ key: 'revenue', value: 1000 }]], + yAxisKey: 'revenue', + }, + dataIndex: 0, + datasetIndex: 0, + } as unknown as TooltipItem, + ]; - it('should correctly format tooltip items', () => { - const keyToUsePercentage = ['percentage']; - const hasMultipleShownDatasets = true; - const mockDataPoint = createMockDataPoint(); + mockFormatLabel + .mockReturnValueOnce('Revenue') // for formattedLabel + .mockReturnValueOnce('$1,000'); // for formattedValue + mockGetPercentage.mockReturnValue('50%'); + // Act const result = barAndLineTooltipHelper( - [mockDataPoint], + mockDataPoints, mockChart, mockColumnLabelFormats, - keyToUsePercentage, - hasMultipleShownDatasets, + [], + false, // hasMultipleShownDatasets = false undefined ); - expect(result).toHaveLength(2); - - // Check the value item + // Assert + expect(result).toHaveLength(1); expect(result[0]).toEqual({ seriesType: 'bar', - color: '#FF0000', + color: '#ff0000', usePercentage: false, - formattedLabel: 'Test Dataset', + formattedLabel: 'Revenue', values: [ { - formattedValue: '250', - formattedLabel: 'Test Dataset', - formattedPercentage: '100%', + formattedValue: '$1,000', + formattedLabel: 'Revenue', + formattedPercentage: '50%', }, ], }); }); - it('should handle stacked percentage mode', () => { + it('should use dataset label when hasMultipleShownDatasets is true', () => { + // Arrange + const mockDataPoints: TooltipItem[] = [ + { + dataset: { + label: 'Revenue Dataset', + backgroundColor: '#ff0000', + tooltipData: [[{ key: 'revenue', value: 1000 }]], + yAxisKey: 'revenue', + }, + dataIndex: 0, + datasetIndex: 0, + } as unknown as TooltipItem, + ]; + + mockFormatLabel.mockReturnValue('$1,000'); + mockGetPercentage.mockReturnValue('25%'); + + // Act const result = barAndLineTooltipHelper( - [createMockDataPoint()], + mockDataPoints, mockChart, mockColumnLabelFormats, [], - true, - 'stacked' + true, // hasMultipleShownDatasets = true + undefined ); - expect(result.every((item) => item.usePercentage)).toBe(true); - result.forEach((item) => { - expect(item.values[0].formattedPercentage).toBeDefined(); - }); + // Assert + expect(mockFormatLabel).toHaveBeenCalledTimes(1); // Only for formattedValue + expect(result).toHaveLength(1); + expect(result[0].formattedLabel).toBe('Revenue Dataset'); // Uses dataset.label + expect(result[0].values[0].formattedLabel).toBe('Revenue Dataset'); }); - it('should handle empty tooltip data', () => { - const emptyDataset = createMockDataset({ tooltipData: [[]] }); - const emptyDataPoint = createMockDataPoint({ dataset: emptyDataset }); + it('should return empty array when tooltipData is missing', () => { + // Arrange + const mockDataPoints: TooltipItem[] = [ + { + dataset: { + label: 'Revenue Dataset', + backgroundColor: '#ff0000', + tooltipData: [null], // No data at this index + yAxisKey: 'revenue', + }, + dataIndex: 0, + datasetIndex: 0, + } as unknown as TooltipItem, + ]; + // Act const result = barAndLineTooltipHelper( - [emptyDataPoint], + mockDataPoints, mockChart, mockColumnLabelFormats, [], @@ -163,15 +135,33 @@ describe('barAndLineTooltipHelper', () => { undefined ); + // Assert expect(result).toHaveLength(0); + expect(mockFormatLabel).not.toHaveBeenCalled(); }); - it('should use default color when yAxisKey does not match item key', () => { - const modifiedDataset = createMockDataset({ yAxisKey: 'different_key' }); - const modifiedDataPoint = createMockDataPoint({ dataset: modifiedDataset }); + it('should use backgroundColor when yAxisKey matches item key', () => { + // Arrange + const mockDataPoints: TooltipItem[] = [ + { + dataset: { + label: 'Revenue Dataset', + backgroundColor: '#ff0000', + borderColor: '#00ff00', + tooltipData: [[{ key: 'revenue', value: 1000 }]], + yAxisKey: 'revenue', // Matches item.key + }, + dataIndex: 0, + datasetIndex: 0, + } as unknown as TooltipItem, + ]; + mockFormatLabel.mockReturnValue('formatted'); + mockGetPercentage.mockReturnValue('30%'); + + // Act const result = barAndLineTooltipHelper( - [modifiedDataPoint], + mockDataPoints, mockChart, mockColumnLabelFormats, [], @@ -179,6 +169,93 @@ describe('barAndLineTooltipHelper', () => { undefined ); - expect(result.every((item) => item.color === undefined)).toBe(true); + // Assert + expect(result[0].color).toBe('#ff0000'); // Uses backgroundColor since yAxisKey matches + }); + + it('should use undefined color when yAxisKey does not match item key', () => { + // Arrange + const mockDataPoints: TooltipItem[] = [ + { + dataset: { + label: 'Revenue Dataset', + backgroundColor: '#ff0000', + tooltipData: [[{ key: 'revenue', value: 1000 }]], + yAxisKey: 'different_key', // Does NOT match item.key + }, + dataIndex: 0, + datasetIndex: 0, + } as unknown as TooltipItem, + ]; + + mockFormatLabel.mockReturnValue('formatted'); + mockGetPercentage.mockReturnValue('30%'); + + // Act + const result = barAndLineTooltipHelper( + mockDataPoints, + mockChart, + mockColumnLabelFormats, + [], + false, + undefined + ); + + // Assert + expect(result[0].color).toBeUndefined(); // Color is undefined when yAxisKey doesn't match + }); + + it('should handle percentage mode with data reversal and usePercentage flags', () => { + // Arrange + const mockDataPoints: TooltipItem[] = [ + { + dataset: { + label: 'First Dataset', + backgroundColor: '#ff0000', + tooltipData: [[{ key: 'revenue', value: 500 }]], + yAxisKey: 'revenue', + }, + dataIndex: 0, + datasetIndex: 0, + } as unknown as TooltipItem, + { + dataset: { + label: 'percentage_metric', // This should trigger usePercentage via keyToUsePercentage + backgroundColor: '#00ff00', + tooltipData: [[{ key: 'percentage', value: 75 }]], + yAxisKey: 'percentage', + }, + dataIndex: 0, + datasetIndex: 1, + } as unknown as TooltipItem, + ]; + + mockFormatLabel.mockReturnValue('formatted'); + mockGetPercentage.mockReturnValue('40%'); + + // Act + const result = barAndLineTooltipHelper( + mockDataPoints, + mockChart, + mockColumnLabelFormats, + ['percentage_metric'], + true, + 'stacked' // percentageMode = 'stacked' + ); + + // Assert + expect(result).toHaveLength(2); + + // Due to percentageMode='stacked', dataPoints are reversed + // First result should be from 'percentage_metric' dataset (originally second) + expect(result[0].usePercentage).toBe(true); // Due to both percentageMode AND keyToUsePercentage + expect(result[0].formattedLabel).toBe('percentage_metric'); + + // Second result should be from 'First Dataset' (originally first) + expect(result[1].usePercentage).toBe(true); // Due to percentageMode + expect(result[1].formattedLabel).toBe('First Dataset'); + + // Verify getPercentage was called for both items + expect(mockGetPercentage).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/components/ui/charts/BusterChartJS/hooks/useSeriesOptions/createTickDate.test.ts b/apps/web/src/components/ui/charts/BusterChartJS/hooks/useSeriesOptions/createTickDate.test.ts new file mode 100644 index 000000000..b82498dd1 --- /dev/null +++ b/apps/web/src/components/ui/charts/BusterChartJS/hooks/useSeriesOptions/createTickDate.test.ts @@ -0,0 +1,252 @@ +import type { ColumnLabelFormat } from '@buster/server-shared/metrics'; +import { describe, expect, it, vi } from 'vitest'; +import { createTickDates } from './createTickDate'; + +// Mock dependencies +vi.mock('@/lib/date', () => ({ + createDayjsDate: vi.fn((dateStr) => ({ + toDate: () => new Date(dateStr), + })), +})); + +vi.mock('@/lib/columnFormatter', () => ({ + formatLabel: vi.fn((value, format) => { + if (format.columnType === 'number' && format.style === 'number') { + return String(value); + } + if (format.columnType === 'date' && format.style === 'date') { + return String(value); + } + return String(value); + }), +})); + +describe('createTickDates', () => { + const createMockFormat = (overrides: Partial): ColumnLabelFormat => ({ + columnType: 'text', + style: 'string', + displayName: '', + numberSeparatorStyle: ',', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + multiplier: 1, + prefix: '', + suffix: '', + replaceMissingDataWith: 0, + useRelativeTime: false, + isUTC: false, + makeLabelHumanReadable: true, + compactNumbers: false, + currency: 'USD', + dateFormat: 'auto', + convertNumberTo: null, + ...overrides, + }); + + it('should return null when no date labels are used (text column)', () => { + const ticks = [['2023-01-01', '2023-02-01']]; + const xAxisKeys = ['date']; + const columnLabelFormats = { + date: createMockFormat({ columnType: 'text', style: 'string' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toBeNull(); + }); + + it('should return null when no date labels are used (number column with number style)', () => { + const ticks = [['100', '200']]; + const xAxisKeys = ['value']; + const columnLabelFormats = { + value: createMockFormat({ columnType: 'number', style: 'number' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toBeNull(); + }); + + it('should process single date axis with date column type and date style', () => { + const ticks = [['2023-01-01'], ['2023-02-01'], ['2023-03-01']]; + const xAxisKeys = ['date']; + const columnLabelFormats = { + date: createMockFormat({ columnType: 'date', style: 'date' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toHaveLength(3); + expect(result?.[0]).toBeInstanceOf(Date); + expect(result?.[1]).toBeInstanceOf(Date); + expect(result?.[2]).toBeInstanceOf(Date); + }); + + it('should process single date axis with number column type and date style', () => { + const ticks = [['2023-01-01'], ['2023-02-01']]; + const xAxisKeys = ['timestamp']; + const columnLabelFormats = { + timestamp: createMockFormat({ columnType: 'number', style: 'date' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toHaveLength(2); + expect(result?.[0]).toBeInstanceOf(Date); + expect(result?.[1]).toBeInstanceOf(Date); + }); + + it('should process single quarter axis correctly', () => { + const ticks = [ + ['2023', '1'], + ['2023', '2'], + ['2023', '3'], + ['2023', '4'], + ]; + const xAxisKeys = ['year', 'quarter']; + const columnLabelFormats = { + year: createMockFormat({ columnType: 'number', style: 'number' }), + quarter: createMockFormat({ + columnType: 'number', + style: 'date', + convertNumberTo: 'quarter', + }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toHaveLength(4); + expect(result?.[0]).toBe('2023 Q1'); + expect(result?.[1]).toBe('2023 Q2'); + expect(result?.[2]).toBe('2023 Q3'); + expect(result?.[3]).toBe('2023 Q4'); + }); + + it('should handle single axis with quarter format (single x-axis)', () => { + const ticks = [['1'], ['2'], ['3'], ['4']]; + const xAxisKeys = ['quarter']; + const columnLabelFormats = { + quarter: createMockFormat({ + columnType: 'number', + convertNumberTo: 'quarter', + style: 'date', // Need this to trigger useDateLabels condition + }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toHaveLength(4); + expect(result?.[0]).toBe('Q1'); + expect(result?.[1]).toBe('Q2'); + expect(result?.[2]).toBe('Q3'); + expect(result?.[3]).toBe('Q4'); + }); + + it('should handle double axis with quarter and number combination', () => { + const ticks = [ + ['2023', '1'], + ['2023', '2'], + ['2024', '1'], + ]; + const xAxisKeys = ['year', 'quarter']; + const columnLabelFormats = { + year: createMockFormat({ columnType: 'number', style: 'number' }), + quarter: createMockFormat({ + columnType: 'number', + convertNumberTo: 'quarter', + style: 'date', // Need this to trigger useDateLabels condition + }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toHaveLength(3); + expect(result?.[0]).toBe('2023 Q1'); + expect(result?.[1]).toBe('2023 Q2'); + expect(result?.[2]).toBe('2024 Q1'); + }); + + it('should return null for double axis without quarter and number combination', () => { + const ticks = [ + ['A', 'B'], + ['C', 'D'], + ]; + const xAxisKeys = ['col1', 'col2']; + const columnLabelFormats = { + col1: createMockFormat({ columnType: 'text', style: 'string' }), + col2: createMockFormat({ columnType: 'text', style: 'string' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toBeNull(); + }); + + it('should use default column label format when key is missing', () => { + const ticks = [['2023-01-01']]; + const xAxisKeys = ['missingKey']; + const columnLabelFormats = {}; // Empty object + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toBeNull(); // Should return null because default format is text/string + }); + + it('should handle multiple ticks in single axis date scenario', () => { + const ticks = [ + ['2023-01-01', '2023-01-02'], + ['2023-02-01', '2023-02-02'], + ['2023-03-01', '2023-03-02'], + ]; + const xAxisKeys = ['date']; + const columnLabelFormats = { + date: createMockFormat({ columnType: 'date', style: 'date' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toHaveLength(6); // 3 ticks * 2 items each = 6 dates + expect(result?.[0]).toBeInstanceOf(Date); + expect(result?.[1]).toBeInstanceOf(Date); + expect(result?.[2]).toBeInstanceOf(Date); + expect(result?.[3]).toBeInstanceOf(Date); + expect(result?.[4]).toBeInstanceOf(Date); + expect(result?.[5]).toBeInstanceOf(Date); + }); + + it('should handle quarter format with correct index positioning', () => { + const ticks = [ + ['1', '2023'], + ['2', '2023'], + ]; + const xAxisKeys = ['quarter', 'year']; + const columnLabelFormats = { + quarter: createMockFormat({ + columnType: 'number', + convertNumberTo: 'quarter', + style: 'date', // Need this to trigger useDateLabels condition + }), + year: createMockFormat({ columnType: 'number', style: 'number' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toHaveLength(2); + expect(result?.[0]).toBe('Q1 2023'); // Quarter is at index 0 + expect(result?.[1]).toBe('Q2 2023'); + }); + + it('should return null for more than 2 x-axis keys', () => { + const ticks = [['A', 'B', 'C']]; + const xAxisKeys = ['col1', 'col2', 'col3']; + const columnLabelFormats = { + col1: createMockFormat({ columnType: 'date', style: 'date' }), + col2: createMockFormat({ columnType: 'date', style: 'date' }), + col3: createMockFormat({ columnType: 'date', style: 'date' }), + }; + + const result = createTickDates(ticks, xAxisKeys, columnLabelFormats); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ui/charts/BusterChartJS/hooks/useSeriesOptions/createTickDate.ts b/apps/web/src/components/ui/charts/BusterChartJS/hooks/useSeriesOptions/createTickDate.ts index 01c081fcb..d9312a57d 100644 --- a/apps/web/src/components/ui/charts/BusterChartJS/hooks/useSeriesOptions/createTickDate.ts +++ b/apps/web/src/components/ui/charts/BusterChartJS/hooks/useSeriesOptions/createTickDate.ts @@ -73,7 +73,7 @@ const createQuarterTickDates = ( // Format is '[Q]Q' - keep [Q] literal, replace the second Q // Then remove the brackets to get clean output like Q1, Q2, etc. return DEFAULT_DATE_FORMAT_QUARTER.replace(/(?