203 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|