472 lines
15 KiB
TypeScript
472 lines
15 KiB
TypeScript
import {
|
|
addDays,
|
|
addMonths,
|
|
endOfDay,
|
|
endOfHour,
|
|
endOfMonth,
|
|
endOfWeek,
|
|
endOfYear,
|
|
startOfDay,
|
|
startOfHour,
|
|
startOfMonth,
|
|
startOfWeek,
|
|
startOfYear,
|
|
subDays,
|
|
subHours,
|
|
subMinutes,
|
|
subMonths,
|
|
subWeeks,
|
|
subYears,
|
|
} from 'date-fns';
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import {
|
|
formatDate,
|
|
generateTimeSeries,
|
|
getAllowedUnits,
|
|
getCompareDate,
|
|
getDateRangeValue,
|
|
getDayOfWeekAsDate,
|
|
getMaxSelectableDate,
|
|
getMinimumUnit,
|
|
getMonthDateRangeValue,
|
|
getOffsetDateRange,
|
|
getTimezone,
|
|
isInvalidDate,
|
|
isValidTimezone,
|
|
maxDate,
|
|
minDate,
|
|
normalizeTimezone,
|
|
parseDateRange,
|
|
parseDateValue,
|
|
} from './date';
|
|
|
|
// A fixed instant used across timezone-sensitive tests. All expected date
|
|
// values are recomputed with the same date-fns helpers the implementation
|
|
// uses, so assertions stay robust regardless of the host timezone.
|
|
const NOW = new Date('2026-07-24T12:00:00');
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(NOW);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('parseDateValue', () => {
|
|
test.each([
|
|
['1hour', { num: 1, unit: 'hour' }],
|
|
['24hour', { num: 24, unit: 'hour' }],
|
|
['1day', { num: 1, unit: 'day' }],
|
|
['7day', { num: 7, unit: 'day' }],
|
|
['1week', { num: 1, unit: 'week' }],
|
|
['1month', { num: 1, unit: 'month' }],
|
|
['12month', { num: 12, unit: 'month' }],
|
|
['1year', { num: 1, unit: 'year' }],
|
|
['0day', { num: 0, unit: 'day' }],
|
|
])('parses %s', (input, expected) => {
|
|
expect(parseDateValue(input)).toEqual(expected);
|
|
});
|
|
|
|
test('returns null for unsupported units', () => {
|
|
expect(parseDateValue('1minute')).toBeNull();
|
|
expect(parseDateValue('1decade')).toBeNull();
|
|
expect(parseDateValue('range:1:2')).toBeNull();
|
|
expect(parseDateValue('')).toBeNull();
|
|
expect(parseDateValue('day')).toBeNull();
|
|
});
|
|
|
|
test('returns null when value lacks a matching pattern (has match method)', () => {
|
|
// Non-string values that still expose (an absent) `.match` are guarded by
|
|
// optional chaining and yield null.
|
|
expect(parseDateValue(123 as any)).toBeNull();
|
|
expect(parseDateValue({} as any)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseDateRange', () => {
|
|
test('returns null for non-string input', () => {
|
|
expect(parseDateRange(null as any)).toBeNull();
|
|
expect(parseDateRange(123 as any)).toBeNull();
|
|
expect(parseDateRange(undefined as any)).toBeNull();
|
|
});
|
|
|
|
test('parses hour ranges', () => {
|
|
const result = parseDateRange('24hour');
|
|
expect(result).toMatchObject({
|
|
unit: 'hour',
|
|
num: 24,
|
|
offset: 0,
|
|
value: '24hour',
|
|
});
|
|
expect(result.startDate).toEqual(subHours(startOfHour(NOW), 24));
|
|
expect(result.endDate).toEqual(endOfHour(NOW));
|
|
});
|
|
|
|
test('parses day ranges', () => {
|
|
const result = parseDateRange('7day');
|
|
expect(result).toMatchObject({
|
|
unit: 'day',
|
|
num: 7,
|
|
offset: 0,
|
|
value: '7day',
|
|
});
|
|
expect(result.startDate).toEqual(subDays(startOfDay(NOW), 7));
|
|
expect(result.endDate).toEqual(endOfDay(NOW));
|
|
});
|
|
|
|
test('parses week ranges', () => {
|
|
const result = parseDateRange('4week');
|
|
expect(result).toMatchObject({
|
|
unit: 'day',
|
|
num: 4,
|
|
offset: 0,
|
|
value: '4week',
|
|
});
|
|
expect(result.startDate).toEqual(subWeeks(startOfWeek(NOW), 4));
|
|
expect(result.endDate).toEqual(endOfWeek(NOW));
|
|
});
|
|
|
|
test('parses month ranges', () => {
|
|
const result = parseDateRange('6month');
|
|
expect(result).toMatchObject({
|
|
unit: 'month',
|
|
num: 6,
|
|
offset: 0,
|
|
value: '6month',
|
|
});
|
|
expect(result.startDate).toEqual(subMonths(startOfMonth(NOW), 6));
|
|
expect(result.endDate).toEqual(endOfMonth(NOW));
|
|
});
|
|
|
|
test('parses year ranges', () => {
|
|
const result = parseDateRange('1year');
|
|
expect(result).toMatchObject({
|
|
unit: 'month',
|
|
num: 1,
|
|
offset: 0,
|
|
value: '1year',
|
|
});
|
|
expect(result.startDate).toEqual(subYears(startOfYear(NOW), 1));
|
|
expect(result.endDate).toEqual(endOfYear(NOW));
|
|
});
|
|
|
|
test('handles zero-num keyword variants ("this period")', () => {
|
|
// 0day => the current day only, unit collapses to hour
|
|
const day = parseDateRange('0day');
|
|
expect(day).toMatchObject({ unit: 'hour', num: 1, offset: 0 });
|
|
expect(day.startDate).toEqual(startOfDay(NOW));
|
|
expect(day.endDate).toEqual(endOfDay(NOW));
|
|
|
|
// 0month => the current month only, unit collapses to day
|
|
const month = parseDateRange('0month');
|
|
expect(month).toMatchObject({ unit: 'day', num: 1 });
|
|
expect(month.startDate).toEqual(startOfMonth(NOW));
|
|
|
|
// 0hour => still one hour, unit stays hour
|
|
const hour = parseDateRange('0hour');
|
|
expect(hour).toMatchObject({ unit: 'hour', num: 1 });
|
|
expect(hour.startDate).toEqual(startOfHour(NOW));
|
|
});
|
|
|
|
test('honors the unitValue override', () => {
|
|
const result = parseDateRange('7day', 'week');
|
|
expect(result.unit).toBe('week');
|
|
expect(result.num).toBe(7);
|
|
});
|
|
|
|
test('parses explicit range values', () => {
|
|
const start = new Date('2026-01-01T00:00:00Z');
|
|
const end = new Date('2026-01-11T00:00:00Z'); // 10 days -> day unit
|
|
const value = `range:${start.getTime()}:${end.getTime()}`;
|
|
|
|
const result = parseDateRange(value);
|
|
expect(result.startDate).toEqual(start);
|
|
expect(result.endDate).toEqual(end);
|
|
expect(result.value).toBe(value);
|
|
expect(result.unit).toBe('day');
|
|
});
|
|
|
|
test('range unit reflects minimum unit of the span', () => {
|
|
const start = new Date('2026-01-01T00:00:00Z');
|
|
const end = new Date('2026-01-01T00:30:00Z'); // 30 minutes
|
|
const value = getDateRangeValue(start, end);
|
|
expect(parseDateRange(value).unit).toBe('minute');
|
|
});
|
|
|
|
test('applies timezone conversion without throwing and preserves metadata', () => {
|
|
const result = parseDateRange('1day', undefined, 'en-US', 'America/New_York');
|
|
expect(result.startDate).toBeInstanceOf(Date);
|
|
expect(result.endDate).toBeInstanceOf(Date);
|
|
expect(result).toMatchObject({ unit: 'day', num: 1, value: '1day' });
|
|
});
|
|
});
|
|
|
|
describe('getOffsetDateRange', () => {
|
|
test('returns the same range when offset is 0', () => {
|
|
const range = parseDateRange('7day');
|
|
expect(getOffsetDateRange(range, 0)).toBe(range);
|
|
});
|
|
|
|
test('shifts day ranges backward', () => {
|
|
const range = parseDateRange('7day');
|
|
const shifted = getOffsetDateRange(range, -1);
|
|
expect(shifted.offset).toBe(-1);
|
|
// change = num * offset = 7 * -1
|
|
expect(shifted.startDate).toEqual(addDays(range.startDate, -7));
|
|
expect(shifted.endDate).toEqual(addDays(range.endDate, -7));
|
|
});
|
|
|
|
test('shifts month ranges and handles month boundaries', () => {
|
|
const range = parseDateRange('1month');
|
|
const shifted = getOffsetDateRange(range, -1);
|
|
expect(shifted.offset).toBe(-1);
|
|
expect(shifted.startDate).toEqual(addMonths(range.startDate, -1));
|
|
expect(shifted.endDate).toEqual(addMonths(range.endDate, -1));
|
|
});
|
|
|
|
test('shifts year ranges (year rollover)', () => {
|
|
const range = parseDateRange('1year');
|
|
const shifted = getOffsetDateRange(range, 1);
|
|
expect(shifted.startDate.getFullYear()).toBe(range.startDate.getFullYear() + 1);
|
|
});
|
|
|
|
test('falls back to unit add function for explicit ranges', () => {
|
|
const start = new Date('2026-01-01T00:00:00Z');
|
|
const end = new Date('2026-01-11T00:00:00Z');
|
|
const range = parseDateRange(getDateRangeValue(start, end));
|
|
// originalUnit from parseDateValue('range:...') is undefined -> default branch
|
|
const shifted = getOffsetDateRange(range, 1);
|
|
expect(shifted.offset).toBe(1);
|
|
expect(shifted.startDate).toBeInstanceOf(Date);
|
|
expect(shifted.endDate).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
describe('getMinimumUnit', () => {
|
|
const base = new Date('2026-01-01T00:00:00Z');
|
|
|
|
test('returns minute for spans up to 60 minutes', () => {
|
|
expect(getMinimumUnit(base, new Date('2026-01-01T00:59:00Z'))).toBe('minute');
|
|
expect(getMinimumUnit(base, new Date('2026-01-01T01:00:00Z'))).toBe('minute');
|
|
});
|
|
|
|
test('returns hour for spans up to 30 days (non date-range)', () => {
|
|
expect(getMinimumUnit(base, new Date('2026-01-15T00:00:00Z'))).toBe('hour');
|
|
});
|
|
|
|
test('date-range mode caps hour at 48 hours', () => {
|
|
const within = new Date('2026-01-02T23:00:00Z'); // ~47h
|
|
const beyond = new Date('2026-01-05T00:00:00Z'); // >48h
|
|
expect(getMinimumUnit(base, within, true)).toBe('hour');
|
|
expect(getMinimumUnit(base, beyond, true)).toBe('day');
|
|
});
|
|
|
|
test('returns day for spans up to 7 calendar months', () => {
|
|
expect(getMinimumUnit(base, new Date('2026-06-01T00:00:00Z'))).toBe('day');
|
|
});
|
|
|
|
test('returns month for spans up to 24 calendar months', () => {
|
|
expect(getMinimumUnit(base, new Date('2027-06-01T00:00:00Z'))).toBe('month');
|
|
});
|
|
|
|
test('returns year for spans beyond 24 calendar months', () => {
|
|
expect(getMinimumUnit(base, new Date('2029-01-01T00:00:00Z'))).toBe('year');
|
|
});
|
|
});
|
|
|
|
describe('getAllowedUnits', () => {
|
|
const base = new Date('2026-01-01T00:00:00Z');
|
|
|
|
test('includes all units for minute-level spans', () => {
|
|
expect(getAllowedUnits(base, new Date('2026-01-01T00:30:00Z'))).toEqual([
|
|
'minute',
|
|
'hour',
|
|
'day',
|
|
'month',
|
|
'year',
|
|
]);
|
|
});
|
|
|
|
test('drops finer units for day-level spans', () => {
|
|
// ~4 months -> minimum unit day
|
|
expect(getAllowedUnits(base, new Date('2026-05-01T00:00:00Z'))).toEqual([
|
|
'day',
|
|
'month',
|
|
'year',
|
|
]);
|
|
});
|
|
|
|
test('maps year minimum down to month as the finest allowed unit', () => {
|
|
expect(getAllowedUnits(base, new Date('2030-01-01T00:00:00Z'))).toEqual(['month', 'year']);
|
|
});
|
|
});
|
|
|
|
describe('getCompareDate', () => {
|
|
const startDate = new Date('2026-07-01T00:00:00Z');
|
|
const endDate = new Date('2026-07-08T00:00:00Z');
|
|
|
|
test('year over year shifts both bounds back one year', () => {
|
|
expect(getCompareDate('yoy', startDate, endDate)).toEqual({
|
|
compare: 'yoy',
|
|
startDate: subYears(startDate, 1),
|
|
endDate: subYears(endDate, 1),
|
|
});
|
|
});
|
|
|
|
test('previous period shifts back by the span length', () => {
|
|
const result = getCompareDate('prev', startDate, endDate);
|
|
expect(result.compare).toBe('prev');
|
|
// 7 days = 10080 minutes
|
|
expect(result.startDate).toEqual(subMinutes(startDate, 10080));
|
|
expect(result.endDate).toEqual(subMinutes(endDate, 10080));
|
|
expect(result.endDate).toEqual(startDate);
|
|
});
|
|
|
|
test('returns empty object for unknown compare modes', () => {
|
|
expect(getCompareDate('unknown', startDate, endDate)).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('maxDate / minDate', () => {
|
|
const a = new Date('2026-01-01T00:00:00Z');
|
|
const b = new Date('2026-06-01T00:00:00Z');
|
|
const c = new Date('2026-12-01T00:00:00Z');
|
|
|
|
test('maxDate returns the latest valid date, ignoring non-dates', () => {
|
|
expect(maxDate(a, b, c)).toEqual(c);
|
|
expect(maxDate(a, null as any, c, undefined as any)).toEqual(c);
|
|
});
|
|
|
|
test('minDate returns the earliest valid date, ignoring non-dates', () => {
|
|
expect(minDate(a, b, c)).toEqual(a);
|
|
expect(minDate(null, a, 'nope', c)).toEqual(a);
|
|
});
|
|
});
|
|
|
|
describe('getDayOfWeekAsDate', () => {
|
|
test('returns a future date, never today', () => {
|
|
const result = getDayOfWeekAsDate(0);
|
|
// Same weekday as start of current week would collide with today -> pushed +7
|
|
expect(result.getTime()).toBeGreaterThan(NOW.getTime());
|
|
});
|
|
|
|
test('later weekdays fall within the current week window', () => {
|
|
const start = startOfWeek(NOW);
|
|
const result = getDayOfWeekAsDate(6);
|
|
expect(result).toEqual(addDays(start, 6));
|
|
});
|
|
});
|
|
|
|
describe('formatDate', () => {
|
|
test('formats a Date with a custom pattern', () => {
|
|
const date = new Date('2026-07-24T00:00:00');
|
|
expect(formatDate(date, 'yyyy-MM-dd')).toBe('2026-07-24');
|
|
});
|
|
|
|
test('accepts string input', () => {
|
|
expect(formatDate('2026-07-24T00:00:00', 'yyyy-MM-dd')).toBe('2026-07-24');
|
|
});
|
|
|
|
test('accepts numeric timestamp input', () => {
|
|
const date = new Date('2026-07-24T00:00:00');
|
|
expect(formatDate(date.getTime(), 'yyyy')).toBe('2026');
|
|
});
|
|
});
|
|
|
|
describe('generateTimeSeries', () => {
|
|
test('fills gaps between min and max with null values (day unit)', () => {
|
|
const min = new Date('2026-07-01T00:00:00');
|
|
const max = new Date('2026-07-05T00:00:00');
|
|
const data = [
|
|
{ x: '2026-07-02T00:00:00', y: 10 },
|
|
{ x: '2026-07-04T00:00:00', y: 20 },
|
|
];
|
|
|
|
const series = generateTimeSeries(data, min, max, 'day', 'en-US');
|
|
|
|
// Inclusive of both ends: Jul 1..5 => 5 buckets
|
|
expect(series).toHaveLength(5);
|
|
expect(series.map(s => s.x)).toEqual([
|
|
'2026-07-01',
|
|
'2026-07-02',
|
|
'2026-07-03',
|
|
'2026-07-04',
|
|
'2026-07-05',
|
|
]);
|
|
expect(series[0].y).toBeNull();
|
|
expect(series[1].y).toBe(10);
|
|
expect(series[3].y).toBe(20);
|
|
expect(series[4].y).toBeNull();
|
|
});
|
|
|
|
test('single bucket when min and max share a period', () => {
|
|
const min = new Date('2026-07-01T03:00:00');
|
|
const max = new Date('2026-07-01T21:00:00');
|
|
const series = generateTimeSeries([], min, max, 'day', 'en-US');
|
|
expect(series).toHaveLength(1);
|
|
expect(series[0].y).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getDateRangeValue / getMonthDateRangeValue', () => {
|
|
test('builds a range token from two dates', () => {
|
|
const start = new Date('2026-01-01T00:00:00Z');
|
|
const end = new Date('2026-02-01T00:00:00Z');
|
|
expect(getDateRangeValue(start, end)).toBe(`range:${start.getTime()}:${end.getTime()}`);
|
|
});
|
|
|
|
test('builds a month range token spanning the whole month', () => {
|
|
const date = new Date('2026-07-15T12:00:00');
|
|
const value = getMonthDateRangeValue(date);
|
|
const [, startTime, endTime] = value.split(':');
|
|
expect(new Date(+startTime)).toEqual(startOfMonth(date));
|
|
expect(new Date(+endTime)).toEqual(endOfMonth(date));
|
|
});
|
|
});
|
|
|
|
describe('getMaxSelectableDate', () => {
|
|
test('returns the later of end-of-year or six months out', () => {
|
|
// For July, +6 months (late Jan next year) is later than end of the year.
|
|
expect(getMaxSelectableDate(NOW)).toEqual(addMonths(NOW, 6));
|
|
});
|
|
|
|
test('picks end of year when it is later than +6 months', () => {
|
|
// Early January: end of this year is far beyond +6 months.
|
|
const january = new Date('2026-01-02T00:00:00');
|
|
expect(getMaxSelectableDate(january)).toEqual(endOfYear(january));
|
|
});
|
|
});
|
|
|
|
describe('timezone helpers', () => {
|
|
test('normalizeTimezone maps deprecated aliases', () => {
|
|
expect(normalizeTimezone('Asia/Calcutta')).toBe('Asia/Kolkata');
|
|
expect(normalizeTimezone('America/New_York')).toBe('America/New_York');
|
|
});
|
|
|
|
test('isValidTimezone accepts real and aliased zones', () => {
|
|
expect(isValidTimezone('America/New_York')).toBe(true);
|
|
expect(isValidTimezone('Asia/Calcutta')).toBe(true);
|
|
expect(isValidTimezone('Not/AZone')).toBe(false);
|
|
});
|
|
|
|
test('getTimezone returns a non-empty string', () => {
|
|
expect(typeof getTimezone()).toBe('string');
|
|
expect(getTimezone().length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('isInvalidDate', () => {
|
|
test('detects invalid Date instances', () => {
|
|
expect(isInvalidDate(new Date('not a date'))).toBe(true);
|
|
});
|
|
|
|
test('valid dates and non-dates are not flagged', () => {
|
|
expect(isInvalidDate(new Date('2026-07-24'))).toBe(false);
|
|
expect(isInvalidDate('2026-07-24')).toBe(false);
|
|
expect(isInvalidDate(null)).toBe(false);
|
|
expect(isInvalidDate(undefined)).toBe(false);
|
|
});
|
|
});
|