| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
- import {
- getDatePlaceholder,
- getTimePlaceholder,
- formatDateInput,
- formatTimeInput,
- parseDateInput,
- parseTimeInput,
- toDateTimeLocalValue,
- applyTimeFormat,
- parseUTCDate,
- formatDate,
- formatDateOnly,
- formatDateTime,
- formatTimeOnly,
- formatETA,
- formatDuration,
- formatRelativeTime,
- localDateKey,
- } from '../../utils/date';
- describe('getDatePlaceholder', () => {
- it('returns MM/DD/YYYY for us format', () => {
- expect(getDatePlaceholder('us')).toBe('MM/DD/YYYY');
- });
- it('returns DD/MM/YYYY for eu format', () => {
- expect(getDatePlaceholder('eu')).toBe('DD/MM/YYYY');
- });
- it('returns YYYY-MM-DD for iso format', () => {
- expect(getDatePlaceholder('iso')).toBe('YYYY-MM-DD');
- });
- it('returns a placeholder for system format', () => {
- const result = getDatePlaceholder('system');
- expect(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']).toContain(result);
- });
- });
- describe('getTimePlaceholder', () => {
- it('returns HH:MM AM/PM for 12h format', () => {
- expect(getTimePlaceholder('12h')).toBe('HH:MM AM/PM');
- });
- it('returns HH:MM for 24h format', () => {
- expect(getTimePlaceholder('24h')).toBe('HH:MM');
- });
- it('returns a placeholder for system format', () => {
- const result = getTimePlaceholder('system');
- expect(['HH:MM AM/PM', 'HH:MM']).toContain(result);
- });
- });
- describe('formatDateInput', () => {
- const date = new Date(2025, 5, 15); // June 15, 2025
- it('formats as MM/DD/YYYY for us format', () => {
- expect(formatDateInput(date, 'us')).toBe('06/15/2025');
- });
- it('formats as DD/MM/YYYY for eu format', () => {
- expect(formatDateInput(date, 'eu')).toBe('15/06/2025');
- });
- it('formats as YYYY-MM-DD for iso format', () => {
- expect(formatDateInput(date, 'iso')).toBe('2025-06-15');
- });
- it('uses toLocaleDateString for system format', () => {
- const result = formatDateInput(date, 'system');
- expect(result).toBeTruthy();
- });
- });
- describe('formatTimeInput', () => {
- it('formats as 12h with AM', () => {
- const date = new Date(2025, 0, 1, 9, 30);
- expect(formatTimeInput(date, '12h')).toBe('9:30 AM');
- });
- it('formats as 12h with PM', () => {
- const date = new Date(2025, 0, 1, 14, 45);
- expect(formatTimeInput(date, '12h')).toBe('2:45 PM');
- });
- it('formats 12:00 as 12:00 PM', () => {
- const date = new Date(2025, 0, 1, 12, 0);
- expect(formatTimeInput(date, '12h')).toBe('12:00 PM');
- });
- it('formats 00:00 as 12:00 AM', () => {
- const date = new Date(2025, 0, 1, 0, 0);
- expect(formatTimeInput(date, '12h')).toBe('12:00 AM');
- });
- it('formats as 24h', () => {
- const date = new Date(2025, 0, 1, 14, 30);
- expect(formatTimeInput(date, '24h')).toBe('14:30');
- });
- it('pads hours in 24h format', () => {
- const date = new Date(2025, 0, 1, 9, 5);
- expect(formatTimeInput(date, '24h')).toBe('09:05');
- });
- });
- describe('parseDateInput', () => {
- it('parses us format MM/DD/YYYY', () => {
- const result = parseDateInput('06/15/2025', 'us');
- expect(result?.getFullYear()).toBe(2025);
- expect(result?.getMonth()).toBe(5); // June
- expect(result?.getDate()).toBe(15);
- });
- it('parses eu format DD/MM/YYYY', () => {
- const result = parseDateInput('15/06/2025', 'eu');
- expect(result?.getFullYear()).toBe(2025);
- expect(result?.getMonth()).toBe(5);
- expect(result?.getDate()).toBe(15);
- });
- it('parses iso format YYYY-MM-DD', () => {
- const result = parseDateInput('2025-06-15', 'iso');
- expect(result?.getFullYear()).toBe(2025);
- expect(result?.getMonth()).toBe(5);
- expect(result?.getDate()).toBe(15);
- });
- it('accepts different separators', () => {
- expect(parseDateInput('06-15-2025', 'us')?.getDate()).toBe(15);
- expect(parseDateInput('15.06.2025', 'eu')?.getDate()).toBe(15);
- expect(parseDateInput('2025/06/15', 'iso')?.getDate()).toBe(15);
- });
- it('returns null for invalid input', () => {
- expect(parseDateInput('', 'us')).toBeNull();
- expect(parseDateInput('invalid', 'us')).toBeNull();
- expect(parseDateInput('13/32/2025', 'us')).toBeNull(); // invalid month
- expect(parseDateInput('01/01/1800', 'us')).toBeNull(); // year out of range
- });
- it('returns null for invalid month', () => {
- expect(parseDateInput('13/01/2025', 'us')).toBeNull();
- expect(parseDateInput('00/01/2025', 'us')).toBeNull();
- });
- it('returns null for invalid day', () => {
- expect(parseDateInput('01/32/2025', 'us')).toBeNull();
- expect(parseDateInput('01/00/2025', 'us')).toBeNull();
- });
- });
- describe('parseTimeInput', () => {
- it('parses 24h format', () => {
- expect(parseTimeInput('14:30')).toEqual({ hours: 14, minutes: 30 });
- expect(parseTimeInput('09:05')).toEqual({ hours: 9, minutes: 5 });
- expect(parseTimeInput('0:00')).toEqual({ hours: 0, minutes: 0 });
- });
- it('parses 12h format with AM', () => {
- expect(parseTimeInput('9:30 AM')).toEqual({ hours: 9, minutes: 30 });
- expect(parseTimeInput('12:00 AM')).toEqual({ hours: 0, minutes: 0 });
- });
- it('parses 12h format with PM', () => {
- expect(parseTimeInput('2:45 PM')).toEqual({ hours: 14, minutes: 45 });
- expect(parseTimeInput('12:00 PM')).toEqual({ hours: 12, minutes: 0 });
- });
- it('is case insensitive for AM/PM', () => {
- expect(parseTimeInput('9:30 am')).toEqual({ hours: 9, minutes: 30 });
- expect(parseTimeInput('2:45 pm')).toEqual({ hours: 14, minutes: 45 });
- });
- it('returns null for invalid input', () => {
- expect(parseTimeInput('')).toBeNull();
- expect(parseTimeInput('invalid')).toBeNull();
- expect(parseTimeInput('25:00')).toBeNull();
- expect(parseTimeInput('12:60')).toBeNull();
- expect(parseTimeInput('-1:00')).toBeNull();
- });
- });
- describe('toDateTimeLocalValue', () => {
- it('formats date to datetime-local value', () => {
- const date = new Date(2025, 5, 15, 14, 30);
- expect(toDateTimeLocalValue(date)).toBe('2025-06-15T14:30');
- });
- it('pads single digit values', () => {
- const date = new Date(2025, 0, 5, 9, 5);
- expect(toDateTimeLocalValue(date)).toBe('2025-01-05T09:05');
- });
- });
- describe('applyTimeFormat', () => {
- it('sets hour12 true for 12h format', () => {
- const options: Intl.DateTimeFormatOptions = {};
- applyTimeFormat(options, '12h');
- expect(options.hour12).toBe(true);
- });
- it('sets hour12 false for 24h format', () => {
- const options: Intl.DateTimeFormatOptions = {};
- applyTimeFormat(options, '24h');
- expect(options.hour12).toBe(false);
- });
- it('leaves hour12 undefined for system format', () => {
- const options: Intl.DateTimeFormatOptions = {};
- applyTimeFormat(options, 'system');
- expect(options.hour12).toBeUndefined();
- });
- it('returns the modified options object', () => {
- const options: Intl.DateTimeFormatOptions = { hour: '2-digit' };
- const result = applyTimeFormat(options, '12h');
- expect(result).toBe(options);
- expect(result.hour).toBe('2-digit');
- });
- });
- describe('parseUTCDate', () => {
- it('returns null for null/undefined input', () => {
- expect(parseUTCDate(null)).toBeNull();
- expect(parseUTCDate(undefined)).toBeNull();
- expect(parseUTCDate('')).toBeNull();
- });
- it('parses ISO string with Z suffix as-is', () => {
- const result = parseUTCDate('2025-06-15T12:00:00Z');
- expect(result).toBeInstanceOf(Date);
- expect(result?.getUTCHours()).toBe(12);
- });
- it('parses ISO string with timezone offset as-is', () => {
- const result = parseUTCDate('2025-06-15T12:00:00+05:00');
- expect(result).toBeInstanceOf(Date);
- });
- it('appends Z to strings without timezone indicator', () => {
- const result = parseUTCDate('2025-06-15T12:00:00');
- expect(result).toBeInstanceOf(Date);
- expect(result?.getUTCHours()).toBe(12);
- });
- });
- describe('formatDate', () => {
- it('returns empty string for null input', () => {
- expect(formatDate(null)).toBe('');
- expect(formatDate(undefined)).toBe('');
- });
- it('formats a valid date string', () => {
- const result = formatDate('2025-06-15T12:00:00Z');
- expect(result).toBeTruthy();
- expect(result).toContain('2025');
- });
- it('accepts custom options', () => {
- const result = formatDate('2025-06-15T12:00:00Z', { year: 'numeric' });
- expect(result).toContain('2025');
- });
- });
- describe('formatDateOnly', () => {
- it('returns empty string for null input', () => {
- expect(formatDateOnly(null)).toBe('');
- });
- it('formats date without time', () => {
- const result = formatDateOnly('2025-06-15T12:00:00Z');
- expect(result).toBeTruthy();
- expect(result).toContain('2025');
- });
- });
- describe('formatDateTime', () => {
- it('returns empty string for null input', () => {
- expect(formatDateTime(null)).toBe('');
- });
- it('formats with 12h time format', () => {
- const result = formatDateTime('2025-06-15T14:00:00Z', '12h');
- expect(result).toBeTruthy();
- });
- it('formats with 24h time format', () => {
- const result = formatDateTime('2025-06-15T14:00:00Z', '24h');
- expect(result).toBeTruthy();
- });
- });
- describe('formatTimeOnly', () => {
- it('formats time with 12h format', () => {
- const date = new Date(2025, 5, 15, 14, 30);
- const result = formatTimeOnly(date, '12h');
- // Locale-agnostic: separator is "." in en_DK, " " (NBSP) in some, ":" elsewhere.
- expect(result).toMatch(/\b0?2\D+30\b/);
- expect(result.toUpperCase()).toContain('PM');
- });
- it('formats time with 24h format', () => {
- const date = new Date(2025, 5, 15, 14, 30);
- const result = formatTimeOnly(date, '24h');
- expect(result).toMatch(/\b14\D+30\b/);
- });
- });
- describe('formatETA', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
- });
- afterEach(() => {
- vi.useRealTimers();
- });
- it('returns time only for same day', () => {
- const result = formatETA(60); // 1 hour from now
- expect(result).toBeTruthy();
- });
- it('includes "Tomorrow" for next day', () => {
- const result = formatETA(60 * 24); // 24 hours from now
- expect(result).toContain('Tomorrow');
- });
- it('uses translation function for tomorrow', () => {
- const t = vi.fn((key: string) => (key === 'common.tomorrow' ? 'Demain' : key));
- const result = formatETA(60 * 24, 'system', t);
- expect(result).toContain('Demain');
- });
- it('shows weekday for dates beyond tomorrow', () => {
- const result = formatETA(60 * 48); // 48 hours from now
- expect(result).not.toContain('Tomorrow');
- });
- it('counts from baseTime when one is supplied', () => {
- const base = new Date('2025-06-15T12:00:00Z').getTime();
- // Same offset, two different starting instants: the results must differ by
- // exactly the gap between those instants, not track the system clock.
- const atNoon = formatETA(60, '24h', undefined, base);
- const anHourLater = formatETA(60, '24h', undefined, base + 60 * 60 * 1000);
- expect(atNoon).not.toBe(anHourLater);
- expect(formatETA(60, '24h', undefined, base)).toBe(atNoon);
- expect(formatETA(120, '24h', undefined, base)).toBe(anHourLater);
- });
- it('falls back to the system clock without baseTime', () => {
- const explicit = formatETA(60, '24h', undefined, Date.now());
- expect(formatETA(60, '24h')).toBe(explicit);
- });
- });
- describe('formatDuration', () => {
- it('returns "--" for null/undefined', () => {
- expect(formatDuration(null)).toBe('--');
- expect(formatDuration(undefined)).toBe('--');
- });
- it('returns "--" for negative values', () => {
- expect(formatDuration(-1)).toBe('--');
- });
- it('formats minutes only when under 1 hour', () => {
- expect(formatDuration(0)).toBe('0m');
- expect(formatDuration(60)).toBe('1m');
- expect(formatDuration(2700)).toBe('45m');
- });
- it('formats hours and minutes', () => {
- expect(formatDuration(3600)).toBe('1h 0m');
- expect(formatDuration(5400)).toBe('1h 30m');
- expect(formatDuration(9000)).toBe('2h 30m');
- });
- });
- describe('formatRelativeTime', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
- });
- afterEach(() => {
- vi.useRealTimers();
- });
- it('returns "-" for null input', () => {
- expect(formatRelativeTime(null)).toBe('-');
- });
- it('returns translated unknown for null with translation', () => {
- const t = vi.fn((key: string) => (key === 'time.unknown' ? 'Unknown' : key));
- expect(formatRelativeTime(null, 'system', t)).toBe('Unknown');
- });
- it('returns "Just now" for times less than 1 minute ago', () => {
- expect(formatRelativeTime('2025-06-15T11:59:30Z')).toBe('Just now');
- });
- it('returns "Now" for times less than 1 minute in future', () => {
- expect(formatRelativeTime('2025-06-15T12:00:30Z')).toBe('Now');
- });
- it('returns minutes ago for times under 1 hour ago', () => {
- expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago');
- expect(formatRelativeTime('2025-06-15T11:30:00Z')).toBe('30m ago');
- });
- it('returns "in Xm" for times under 1 hour in future', () => {
- expect(formatRelativeTime('2025-06-15T12:05:00Z')).toBe('in 5m');
- expect(formatRelativeTime('2025-06-15T12:30:00Z')).toBe('in 30m');
- });
- it('returns hours ago for times under 1 day ago', () => {
- expect(formatRelativeTime('2025-06-15T10:00:00Z')).toBe('2h ago');
- expect(formatRelativeTime('2025-06-15T06:00:00Z')).toBe('6h ago');
- });
- it('returns "in Xh" for times under 1 day in future', () => {
- expect(formatRelativeTime('2025-06-15T14:00:00Z')).toBe('in 2h');
- expect(formatRelativeTime('2025-06-15T18:00:00Z')).toBe('in 6h');
- });
- it('returns days ago for times under 7 days ago', () => {
- expect(formatRelativeTime('2025-06-14T12:00:00Z')).toBe('1d ago');
- expect(formatRelativeTime('2025-06-10T12:00:00Z')).toBe('5d ago');
- });
- it('returns "in Xd" for times under 7 days in future', () => {
- expect(formatRelativeTime('2025-06-16T12:00:00Z')).toBe('in 1d');
- expect(formatRelativeTime('2025-06-20T12:00:00Z')).toBe('in 5d');
- });
- it('returns formatted date for times older than 7 days', () => {
- const result = formatRelativeTime('2025-06-01T12:00:00Z');
- expect(result).toContain('2025');
- });
- it('uses translation function when provided', () => {
- const t = vi.fn((key: string, options?: Record<string, unknown>) => {
- if (key === 'time.minsAgo') return `${options?.count} minutes ago`;
- if (key === 'time.inMins') return `in ${options?.count} minutes`;
- return key;
- });
- expect(formatRelativeTime('2025-06-15T11:55:00Z', 'system', t)).toBe('5 minutes ago');
- expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');
- });
- });
- describe('localDateKey (#1446 — Print Activity heatmap bucketing)', () => {
- // We can't change Node's process.env.TZ at runtime (it's resolved once),
- // but localDateKey's whole point is to use the *local* tz getters on the
- // Date. So we feed it a Date object whose local components we control via
- // the Date constructor's local-time form — that's equivalent to "the user
- // is in tz X and the UTC timestamp lands on day Y locally."
- it('keys a Date by its local-tz date, not UTC', () => {
- // A Date whose local-tz representation is May 17, 2026 at 22:00. In CDT
- // (UTC-5) the UTC representation would be May 18 03:00 — but the bucket
- // key must follow the local view, which is what the heatmap renders.
- const localEvening = new Date(2026, 4, 17, 22, 0, 0); // months are 0-indexed
- expect(localDateKey(localEvening)).toBe('2026-05-17');
- });
- it('reproduces the #1446 repro: a UTC string that is "tomorrow UTC" but "today local" keys to today local', () => {
- // Reporter's row 30: stored as 2026-05-18 03:39:07 UTC, local was
- // 22:39 CDT May 17. The heatmap key must be 2026-05-17 in any local tz
- // whose offset puts that UTC moment back into May 17. We construct the
- // equivalent moment via a local-time Date object so the test is
- // independent of which tz the CI runner is in.
- const localMoment = new Date(2026, 4, 17, 22, 39, 7);
- expect(localDateKey(localMoment)).toBe('2026-05-17');
- });
- it('returns an empty string for null / undefined / empty input', () => {
- expect(localDateKey('')).toBe('');
- // null and undefined typed via the string overload are still defensively
- // handled by parseUTCDate, which returns null and we shortcut to ''.
- expect(localDateKey(null as unknown as string)).toBe('');
- expect(localDateKey(undefined as unknown as string)).toBe('');
- });
- it('pads single-digit month and day to two digits', () => {
- const jan3 = new Date(2026, 0, 3, 10, 0, 0);
- expect(localDateKey(jan3)).toBe('2026-01-03');
- });
- it('accepts an ISO string and parses it via parseUTCDate', () => {
- // parseUTCDate treats naked ISO as UTC and converts to local. Verify
- // the chain works end-to-end — a UTC-anchored input becomes a local
- // YYYY-MM-DD bucket key.
- const key = localDateKey('2026-05-17T16:27:23');
- // The exact local date depends on the CI runner's tz, but the result
- // must always be a well-formed YYYY-MM-DD (10 chars, two dashes).
- expect(key).toMatch(/^\d{4}-\d{2}-\d{2}$/);
- });
- });
|