Files
superset2/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts
2026-07-18 10:28:03 -07:00

352 lines
14 KiB
TypeScript

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 {
createSmartDateFormatter,
createSmartDateVerboseFormatter,
getTimeFormatterRegistry,
NumberFormats,
SMART_DATE_ID,
SMART_DATE_VERBOSE_ID,
TimeFormatter,
TimeGranularity,
} from '@superset-ui/core';
import {
getPercentFormatter,
getTooltipTimeFormatter,
getXAxisFormatter,
} from '../../src/utils/formatters';
// The app normally registers these via setupFormatters() at bootstrap.
// Register them here so tests that check actual formatted output (not just
// formatter identity) exercise the real smart-date formatting logic instead
// of falling back to treating "smart_date" as a literal d3 format string.
beforeAll(() => {
getTimeFormatterRegistry()
.registerValue(SMART_DATE_ID, createSmartDateFormatter())
.registerValue(SMART_DATE_VERBOSE_ID, createSmartDateVerboseFormatter());
});
test('getPercentFormatter should format as percent if no format is specified', () => {
const value = 0.6;
expect(getPercentFormatter().format(value)).toEqual('60%');
});
test('getPercentFormatter should format as percent if SMART_NUMBER is specified', () => {
const value = 0.6;
expect(getPercentFormatter(NumberFormats.SMART_NUMBER).format(value)).toEqual(
'60%',
);
});
test('getPercentFormatter should format using a provided format', () => {
const value = 0.6;
expect(
getPercentFormatter(NumberFormats.PERCENT_2_POINT).format(value),
).toEqual('60.00%');
});
test('getXAxisFormatter should return smart date formatter for SMART_DATE_ID format', () => {
const formatter = getXAxisFormatter(SMART_DATE_ID);
expect(formatter).toBeDefined();
expect(formatter).toBeInstanceOf(TimeFormatter);
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_ID);
});
test('getXAxisFormatter should return smart date formatter for undefined format', () => {
const formatter = getXAxisFormatter();
expect(formatter).toBeDefined();
expect(formatter).toBeInstanceOf(TimeFormatter);
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_ID);
});
test('getXAxisFormatter should return custom time formatter for custom format', () => {
const customFormat = '%Y-%m-%d';
const formatter = getXAxisFormatter(customFormat);
expect(formatter).toBeDefined();
expect(formatter).toBeInstanceOf(TimeFormatter);
expect((formatter as TimeFormatter).id).toBe(customFormat);
});
test('getXAxisFormatter smart date formatter should be returned and not undefined', () => {
const formatter = getXAxisFormatter(SMART_DATE_ID);
expect(formatter).toBeDefined();
expect(formatter).toBeInstanceOf(TimeFormatter);
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_ID);
const undefinedFormatter = getXAxisFormatter(undefined);
expect(undefinedFormatter).toBeDefined();
expect(undefinedFormatter).toBeInstanceOf(TimeFormatter);
expect((undefinedFormatter as TimeFormatter).id).toBe(SMART_DATE_ID);
const emptyFormatter = getXAxisFormatter();
expect(emptyFormatter).toBeDefined();
expect(emptyFormatter).toBeInstanceOf(TimeFormatter);
expect((emptyFormatter as TimeFormatter).id).toBe(SMART_DATE_ID);
});
test('getXAxisFormatter time grain aware formatter should prevent millisecond and timestamp formats', () => {
const formatter = getXAxisFormatter(SMART_DATE_ID, TimeGranularity.MONTH);
// Test that dates with milliseconds don't show millisecond format
const dateWithMs = new Date('2025-03-15T21:13:32.389Z');
const result = (formatter as TimeFormatter).format(dateWithMs);
expect(result).not.toContain('.389ms');
expect(result).not.toMatch(/\.\d+ms/);
expect(result).not.toContain('PM');
expect(result).not.toContain('AM');
expect(result).not.toMatch(/\d{1,2}:\d{2}/); // No time format
});
test('getXAxisFormatter time grain aware formatting should prevent problematic formats', () => {
// Test that time grain aware formatter prevents the specific issues we solved
const monthFormatter = getXAxisFormatter(
SMART_DATE_ID,
TimeGranularity.MONTH,
);
const yearFormatter = getXAxisFormatter(SMART_DATE_ID, TimeGranularity.YEAR);
const dayFormatter = getXAxisFormatter(SMART_DATE_ID, TimeGranularity.DAY);
// Test dates that previously caused issues
const problematicDates = [
new Date('2025-03-15T21:13:32.389Z'), // Had .389ms issue
new Date('2025-04-01T02:30:00.000Z'), // Timezone edge case
new Date('2025-07-01T00:00:00.000Z'), // Month boundary
];
problematicDates.forEach(date => {
// Month formatter should not show milliseconds or PM/AM
const monthResult = (monthFormatter as TimeFormatter).format(date);
expect(monthResult).not.toMatch(/\.\d+ms/);
expect(monthResult).not.toMatch(/PM|AM/);
expect(monthResult).not.toMatch(/\d{1,2}:\d{2}:\d{2}/);
// Year formatter should not show milliseconds or PM/AM
const yearResult = (yearFormatter as TimeFormatter).format(date);
expect(yearResult).not.toMatch(/\.\d+ms/);
expect(yearResult).not.toMatch(/PM|AM/);
expect(yearResult).not.toMatch(/\d{1,2}:\d{2}:\d{2}/);
// Day formatter should not show milliseconds or seconds
const dayResult = (dayFormatter as TimeFormatter).format(date);
expect(dayResult).not.toMatch(/\.\d+ms/);
expect(dayResult).not.toMatch(/:\d{2}:\d{2}/); // No seconds
});
});
test('getXAxisFormatter time grain parameter should be passed correctly', () => {
// Test that formatter with time grain is different from formatter without
const formatterWithGrain = getXAxisFormatter(
SMART_DATE_ID,
TimeGranularity.MONTH,
);
const formatterWithoutGrain = getXAxisFormatter(SMART_DATE_ID);
expect(formatterWithGrain).toBeDefined();
expect(formatterWithoutGrain).toBeDefined();
expect(formatterWithGrain).toBeInstanceOf(TimeFormatter);
expect(formatterWithoutGrain).toBeInstanceOf(TimeFormatter);
// Both should be valid formatters
const testDate = new Date('2025-04-15T12:30:45.789Z');
const resultWithGrain = (formatterWithGrain as TimeFormatter).format(
testDate,
);
const resultWithoutGrain = (formatterWithoutGrain as TimeFormatter).format(
testDate,
);
expect(typeof resultWithGrain).toBe('string');
expect(typeof resultWithoutGrain).toBe('string');
expect(resultWithGrain.length).toBeGreaterThan(0);
expect(resultWithoutGrain.length).toBeGreaterThan(0);
});
test('getXAxisFormatter without time grain should use standard smart date behavior', () => {
const standardFormatter = getXAxisFormatter(SMART_DATE_ID);
const timeGrainFormatter = getXAxisFormatter(SMART_DATE_ID, undefined);
// Both should be equivalent when no time grain is provided
expect(standardFormatter).toBeDefined();
expect(timeGrainFormatter).toBeDefined();
// Test with a date that has time components
const testDate = new Date('2025-01-01T00:00:00.000Z');
const standardResult = (standardFormatter as TimeFormatter).format(testDate);
const timeGrainResult = (timeGrainFormatter as TimeFormatter).format(
testDate,
);
expect(standardResult).toBe(timeGrainResult);
});
// Regression tests for echarts-timeseries-epoch-x-axis-labels investigation.
// The bug report was that temporal x-axis labels could render as "NaN"
// in some edge cases that we could not reproduce locally. The tests below
// lock in the current behavior of the formatters so that a future refactor
// surfaces any change in contract.
test('getTooltipTimeFormatter returns a TimeFormatter with SMART_DATE_VERBOSE id for SMART_DATE_ID', () => {
const formatter = getTooltipTimeFormatter(SMART_DATE_ID);
expect(formatter).toBeInstanceOf(TimeFormatter);
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_VERBOSE_ID);
});
test('getTooltipTimeFormatter returns a TimeFormatter for a custom format string', () => {
const customFormat = '%Y-%m-%d %H:%M';
const formatter = getTooltipTimeFormatter(customFormat);
expect(formatter).toBeInstanceOf(TimeFormatter);
expect((formatter as TimeFormatter).id).toBe(customFormat);
});
test('getTooltipTimeFormatter falls back to the String constructor when no format is supplied', () => {
expect(getTooltipTimeFormatter()).toBe(String);
expect(getTooltipTimeFormatter(undefined)).toBe(String);
});
test('getTooltipTimeFormatter respects the time grain for the SMART_DATE path', () => {
// With a time grain active and no explicit format, the tooltip should read
// grain-appropriate labels rather than a raw timestamp. UTC-based dates keep
// the assertions deterministic across CI/developer timezones.
const date = new Date(Date.UTC(2021, 0, 7));
const dayFormatter = getTooltipTimeFormatter(
SMART_DATE_ID,
TimeGranularity.DAY,
) as TimeFormatter;
expect(dayFormatter.format(date)).toEqual('2021-01-07');
const weekFormatter = getTooltipTimeFormatter(
SMART_DATE_ID,
TimeGranularity.WEEK,
) as TimeFormatter;
expect(weekFormatter.format(date)).toEqual('2021-01-07 — 2021-01-13');
const monthFormatter = getTooltipTimeFormatter(
SMART_DATE_ID,
TimeGranularity.MONTH,
) as TimeFormatter;
expect(monthFormatter.format(date)).toEqual(expect.stringContaining('Jan'));
expect(monthFormatter.format(date)).toEqual(expect.stringContaining('2021'));
const quarterFormatter = getTooltipTimeFormatter(
SMART_DATE_ID,
TimeGranularity.QUARTER,
) as TimeFormatter;
expect(quarterFormatter.format(date)).toEqual(expect.stringContaining('Q1'));
expect(quarterFormatter.format(date)).toEqual(
expect.stringContaining('2021'),
);
const yearFormatter = getTooltipTimeFormatter(
SMART_DATE_ID,
TimeGranularity.YEAR,
) as TimeFormatter;
expect(yearFormatter.format(date)).toEqual('2021');
});
test('getTooltipTimeFormatter applies the time grain even without an explicit format', () => {
const date = new Date(Date.UTC(2021, 0, 7));
const monthFormatter = getTooltipTimeFormatter(
undefined,
TimeGranularity.MONTH,
) as TimeFormatter;
expect(monthFormatter).toBeInstanceOf(TimeFormatter);
expect(monthFormatter.format(date)).toEqual(expect.stringContaining('2021'));
});
test('getTooltipTimeFormatter honors an explicit custom format over the time grain', () => {
// A user-pinned format must win, so the grain does not turn a single date
// into a range or otherwise override the requested format.
const formatter = getTooltipTimeFormatter(
'%Y-%m-%d',
TimeGranularity.YEAR,
) as TimeFormatter;
expect(formatter).toBeInstanceOf(TimeFormatter);
expect(formatter.id).toBe('%Y-%m-%d');
expect(formatter.format(new Date(Date.UTC(2021, 0, 7)))).toEqual(
'2021-01-07',
);
});
test('getXAxisFormatter produces stable SMART_DATE output for a valid Date', () => {
// Documents the current happy-path output format so unexpected changes are
// caught during review.
const formatter = getXAxisFormatter(SMART_DATE_ID) as TimeFormatter;
const result = formatter.format(new Date('2025-01-15T00:00:00.000Z'));
expect(typeof result).toBe('string');
expect(result).not.toMatch(/NaN/);
expect(result.length).toBeGreaterThan(0);
});
test('getXAxisFormatter returns a string for an Invalid Date without throwing', () => {
// If a caller ever passes an Invalid Date (the originally-suspected cause
// of epoch-ms axis labels showing NaN in echarts), the formatter must
// still return a string instead of throwing, so echarts does not blow up
// the chart render. The *content* of that string is format-dependent and
// intentionally not asserted here — only that it is a string.
const formatter = getXAxisFormatter(SMART_DATE_ID) as TimeFormatter;
const invalid = new Date(Number.NaN);
expect(() => formatter.format(invalid)).not.toThrow();
expect(typeof formatter.format(invalid)).toBe('string');
const customFormatter = getXAxisFormatter('%Y-%m-%d') as TimeFormatter;
expect(() => customFormatter.format(invalid)).not.toThrow();
expect(typeof customFormatter.format(invalid)).toBe('string');
});
test('getSmartDateFormatter MINUTE grain distinguishes different minutes', () => {
const formatter = getXAxisFormatter(
SMART_DATE_ID,
TimeGranularity.MINUTE,
) as TimeFormatter;
const date1 = new Date('2024-01-15T10:15:00Z');
const date2 = new Date('2024-01-15T10:30:00Z');
expect(formatter.format(date1)).not.toBe(formatter.format(date2));
});
test('getSmartDateFormatter FIFTEEN_MINUTES grain distinguishes different minutes', () => {
const formatter = getXAxisFormatter(
SMART_DATE_ID,
TimeGranularity.FIFTEEN_MINUTES,
) as TimeFormatter;
const date1 = new Date('2024-01-15T10:15:00Z');
const date2 = new Date('2024-01-15T10:30:00Z');
expect(formatter.format(date1)).not.toBe(formatter.format(date2));
});
test('getSmartDateFormatter HOUR grain collapses minutes to same label', () => {
const formatter = getXAxisFormatter(
SMART_DATE_ID,
TimeGranularity.HOUR,
) as TimeFormatter;
const date1 = new Date('2024-01-15T10:00:00Z');
const date2 = new Date('2024-01-15T10:35:00Z');
expect(formatter.format(date1)).toBe(formatter.format(date2));
});
test('getSmartDateFormatter SECOND grain distinguishes different seconds', () => {
const formatter = getXAxisFormatter(
SMART_DATE_ID,
TimeGranularity.SECOND,
) as TimeFormatter;
const date1 = new Date('2024-01-15T10:35:00Z');
const date2 = new Date('2024-01-15T10:35:45Z');
expect(formatter.format(date1)).not.toBe(formatter.format(date2));
});