Files

203 lines
8.0 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { get } from 'svelte/store';
import {
availableTimeZones,
createTimeFormatter,
DEFAULT_TIME_ZONE,
validTimeZone,
timestampISO
} from '../../src/lib/time-zone.js';
import {
startTimeZonePreference,
timeFormat,
timeZone,
timeZonePersistenceError,
TIME_ZONE_STORAGE_KEY
} from '../../src/lib/ui/stores/time-zone.js';
let stop: (() => void) | undefined;
afterEach(() => {
stop?.();
stop = undefined;
timeZone.set(DEFAULT_TIME_ZONE);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('timezone formatting', () => {
const ny = createTimeFormatter(DEFAULT_TIME_ZONE);
it('uses New York by default, including when a saved zone is invalid', () => {
expect(get(timeZone)).toBe('America/New_York');
expect(createTimeFormatter('invalid').dateTime('2026-01-15T12:00:00Z')).toBe(
'Jan 15, 2026, 7:00:00 AM EST'
);
expect(ny.dateTime('2026-07-15T12:00:00Z')).toBe('Jul 15, 2026, 8:00:00 AM EDT');
});
it('jumps over the missing spring hour and distinguishes repeated fall hours', () => {
expect(ny.time('2026-03-08T06:59:59Z')).toBe('1:59:59 AM EST');
expect(ny.time('2026-03-08T07:00:00Z')).toBe('3:00:00 AM EDT');
expect(ny.period('2026-11-01T05:30:00Z')).toBe('Nov 1, 2026, 1:30 AM EDT');
expect(ny.period('2026-11-01T06:30:00Z')).toBe('Nov 1, 2026, 1:30 AM EST');
});
it('uses the selected calendar day for date-only displays and history groups', () => {
const at = '2026-01-01T02:00:00Z';
expect(ny.date(at)).toBe('Dec 31, 2025');
expect(createTimeFormatter('Asia/Tokyo').date(at)).toBe('Jan 1, 2026');
expect(ny.dateTime(at)).toContain('Dec 31, 2025');
});
it('handles half-hour offsets, epoch values, and Date objects', () => {
const format = createTimeFormatter('Asia/Kolkata');
const at = Date.parse('2026-01-15T12:00:00Z');
expect(format.minuteTime(at)).toBe('5:30 PM GMT+5:30');
expect(format.dateTime(new Date(at))).toBe(format.dateTime(at));
expect(createTimeFormatter('UTC').time(0)).toBe('12:00:00 AM UTC');
});
it('interprets SQLite and unzoned ISO timestamps as UTC while preserving explicit offsets', () => {
const expected = ny.dateTime('2026-01-15T12:00:00Z');
for (const at of [
'2026-01-15 12:00:00',
'2026-01-15T12:00:00',
'2026-01-15 12:00:00.123',
'2026-01-15T07:00:00-05:00'
]) {
expect(ny.dateTime(at)).toBe(expected);
}
});
it('renders missing or invalid timestamps without crashing the page', () => {
for (const value of [null, undefined, '', 'bad timestamp', NaN, new Date(NaN)]) {
expect(ny.dateTime(value)).toBe('—');
expect(ny.relative(value)).toBe('—');
expect(timestampISO(value)).toBeUndefined();
}
});
it('shares compact recent times, exact older dates, and UTC normalization', () => {
const now = Date.parse('2026-10-07T18:00:00Z');
expect(ny.relative(now - 2_000, now)).toBe('Just now');
expect(ny.relative('2026-10-07 17:59:48', now)).toBe('12 s ago');
expect(ny.relative(now - 120_000, now)).toBe('2 min ago');
expect(ny.relative(now - 23 * 3_600_000, now)).toBe('23 h ago');
expect(ny.relative('2026-10-06T17:00:00Z', now)).toBe('Yesterday, 1:00 PM');
expect(ny.relative('2026-10-05T17:00:00Z', now)).toBe('Oct 5, 2026, 1:00 PM EDT');
expect(ny.relative('2026-10-08T18:00:00Z', now)).toBe('Oct 8, 2026, 2:00 PM EDT');
expect(timestampISO('2026-10-07 17:59:48')).toBe('2026-10-07T17:59:48.000Z');
});
it('uses timezone calendar dates for yesterday across DST and year boundaries', () => {
expect(ny.relative('2026-11-01T04:30:00Z', Date.parse('2026-11-02T05:15:00Z'))).toBe(
'Yesterday, 12:30 AM'
);
expect(ny.relative('2025-12-31T05:00:00Z', Date.parse('2026-01-01T06:00:00Z'))).toBe(
'Yesterday, 12:00 AM'
);
expect(
createTimeFormatter('invalid').relative(
'2026-10-05T17:00:00Z',
Date.parse('2026-10-07T18:00:00Z')
)
).toBe('Oct 5, 2026, 1:00 PM EDT');
});
it('offers valid zones including New York and UTC, with an older-browser fallback', () => {
expect(availableTimeZones()).toContain(DEFAULT_TIME_ZONE);
expect(availableTimeZones()).toContain('UTC');
expect(validTimeZone('America/Los_Angeles')).toBe(true);
for (const value of [null, '', 'invalid', 123]) expect(validTimeZone(value)).toBe(false);
vi.spyOn(Intl, 'supportedValuesOf').mockImplementation(() => ['Europe/London']);
expect(availableTimeZones()).toEqual(['America/New_York', 'Europe/London', 'UTC']);
vi.stubGlobal(
'Intl',
new Proxy(Intl, {
get: (target, key) =>
key === 'supportedValuesOf' ? undefined : Reflect.get(target, key)
})
);
expect(availableTimeZones()).toContain('Asia/Kolkata');
expect(availableTimeZones().every(validTimeZone)).toBe(true);
});
});
function browser(saved?: string) {
const values = new Map(saved ? [[TIME_ZONE_STORAGE_KEY, saved]] : []);
const storage = {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
values.set(key, value);
})
};
const window = new EventTarget();
vi.stubGlobal('localStorage', storage);
vi.stubGlobal('window', window);
function change(newValue: string | null, key: string | null = TIME_ZONE_STORAGE_KEY) {
window.dispatchEvent(
Object.assign(new Event('storage'), { storageArea: storage, key, newValue })
);
}
return { values, storage, change };
}
describe('timezone preference', () => {
it('restores the saved zone and updates subscribed formatters and persistence immediately', () => {
const { values } = browser('UTC');
stop = startTimeZonePreference();
let shown = '';
const unsubscribe = timeFormat.subscribe((format) => {
shown = format.time('2026-01-15T12:00:00Z');
});
expect(shown).toBe('12:00:00 PM UTC');
timeZone.set('America/New_York');
expect(shown).toBe('7:00:00 AM EST');
expect(values.get(TIME_ZONE_STORAGE_KEY)).toBe('America/New_York');
stop();
stop = startTimeZonePreference();
expect(get(timeZone)).toBe('America/New_York');
unsubscribe();
});
it.each([undefined, 'invalid'])(
'defaults safely with a missing or corrupt preference (%s)',
(saved) => {
browser(saved);
stop = startTimeZonePreference();
expect(get(timeZone)).toBe(DEFAULT_TIME_ZONE);
}
);
it('syncs changes from other tabs and removes listeners on cleanup', () => {
const { change } = browser();
stop = startTimeZonePreference();
change('Asia/Tokyo');
expect(get(timeZone)).toBe('Asia/Tokyo');
change('UTC', 'unrelated-key');
expect(get(timeZone)).toBe('Asia/Tokyo');
change(null, null);
expect(get(timeZone)).toBe(DEFAULT_TIME_ZONE);
stop();
stop = undefined;
change('UTC');
expect(get(timeZone)).toBe(DEFAULT_TIME_ZONE);
});
it('keeps the preference usable when browser storage is blocked', () => {
const { storage } = browser();
storage.getItem.mockImplementation(() => {
throw new Error('Storage blocked');
});
storage.setItem.mockImplementation(() => {
throw new Error('Storage blocked');
});
expect(() => {
stop = startTimeZonePreference();
}).not.toThrow();
timeZone.set('UTC');
expect(get(timeFormat).time('2026-01-15T12:00:00Z')).toBe('12:00:00 PM UTC');
expect(get(timeZonePersistenceError)).toBe(true);
});
});