mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 08:07:23 +00:00
235 lines
7.3 KiB
TypeScript
235 lines
7.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
parseDuration,
|
|
parseLocalDateTime,
|
|
eventTimeRange,
|
|
eventsOnDay,
|
|
normalizeAllDayDuration,
|
|
buildAllDayDuration,
|
|
getCalendarColor,
|
|
getEventStartDate,
|
|
getEventEndDate,
|
|
timePattern,
|
|
layoutOverlappingEvents,
|
|
CALENDAR_COLOR_PALETTE,
|
|
} from '../calendar-utils';
|
|
import type { CalendarEvent } from '../../api/types';
|
|
|
|
function ev(partial: Partial<CalendarEvent>): CalendarEvent {
|
|
return {
|
|
id: 'e',
|
|
uid: 'u',
|
|
title: 't',
|
|
start: '2026-04-20T09:00:00',
|
|
calendarIds: { 'cal-1': true },
|
|
...partial,
|
|
} as CalendarEvent;
|
|
}
|
|
|
|
describe('parseDuration', () => {
|
|
it('parses hours and minutes', () => {
|
|
expect(parseDuration('PT1H30M')).toBe((60 + 30) * 60 * 1000);
|
|
});
|
|
it('parses days', () => {
|
|
expect(parseDuration('P2D')).toBe(2 * 24 * 60 * 60 * 1000);
|
|
});
|
|
it('parses minutes only', () => {
|
|
expect(parseDuration('PT45M')).toBe(45 * 60 * 1000);
|
|
});
|
|
it('returns 0 for undefined', () => {
|
|
expect(parseDuration(undefined)).toBe(0);
|
|
});
|
|
it('returns 0 for malformed', () => {
|
|
expect(parseDuration('not-a-duration')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('parseLocalDateTime', () => {
|
|
it('parses floating local time without applying timezone shift', () => {
|
|
const d = parseLocalDateTime('2026-04-20T09:00:00');
|
|
expect(d.getFullYear()).toBe(2026);
|
|
expect(d.getMonth()).toBe(3);
|
|
expect(d.getDate()).toBe(20);
|
|
expect(d.getHours()).toBe(9);
|
|
});
|
|
});
|
|
|
|
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
|
|
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
|
|
// floating `start` + duration instead of producing an Invalid Date, which
|
|
// would silently drop the event from the day index and every view.
|
|
it('falls back to start when utcStart is unparseable', () => {
|
|
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
|
|
expect(isNaN(d.getTime())).toBe(false);
|
|
expect(d.getHours()).toBe(9);
|
|
});
|
|
|
|
it('falls back to start + duration when utcEnd is unparseable', () => {
|
|
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
|
|
expect(isNaN(end.getTime())).toBe(false);
|
|
const start = getEventStartDate(ev({}));
|
|
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
|
|
});
|
|
});
|
|
|
|
describe('timePattern', () => {
|
|
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
|
|
expect(timePattern('12h')).toBe('h:mm a');
|
|
expect(timePattern('24h')).toBe('HH:mm');
|
|
expect(timePattern(undefined)).toBe('HH:mm');
|
|
});
|
|
});
|
|
|
|
describe('eventTimeRange', () => {
|
|
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
|
|
const range = eventTimeRange(ev({
|
|
utcStart: '2026-04-20T09:00:00Z',
|
|
utcEnd: '2026-04-20T10:00:00Z',
|
|
duration: 'PT1H',
|
|
}));
|
|
expect(range.allDay).toBe(false);
|
|
expect(range.end.getTime() - range.start.getTime()).toBe(60 * 60 * 1000);
|
|
});
|
|
|
|
it('falls back to start + duration when utcEnd missing', () => {
|
|
const range = eventTimeRange(ev({ duration: 'PT2H' }));
|
|
expect(range.end.getTime() - range.start.getTime()).toBe(2 * 60 * 60 * 1000);
|
|
});
|
|
|
|
it('marks allDay when showWithoutTime', () => {
|
|
const range = eventTimeRange(ev({ showWithoutTime: true, duration: 'P1D' }));
|
|
expect(range.allDay).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('eventsOnDay', () => {
|
|
it('returns events that start on the given day', () => {
|
|
const day = new Date(2026, 3, 20);
|
|
const events = [
|
|
ev({ id: 'a', start: '2026-04-20T09:00:00', duration: 'PT1H' }),
|
|
ev({ id: 'b', start: '2026-04-21T09:00:00', duration: 'PT1H' }),
|
|
];
|
|
const result = eventsOnDay(events, day);
|
|
expect(result.map((e) => e.id)).toEqual(['a']);
|
|
});
|
|
|
|
it('returns multi-day events overlapping the given day', () => {
|
|
const day = new Date(2026, 3, 21);
|
|
const events = [
|
|
ev({
|
|
id: 'multi',
|
|
start: '2026-04-20T00:00:00',
|
|
showWithoutTime: true,
|
|
duration: 'P3D',
|
|
}),
|
|
];
|
|
const result = eventsOnDay(events, day);
|
|
expect(result.map((e) => e.id)).toEqual(['multi']);
|
|
});
|
|
|
|
it('does not return events that start exactly at midnight of the next day', () => {
|
|
const day = new Date(2026, 3, 20);
|
|
const events = [
|
|
ev({
|
|
id: 'edge',
|
|
start: '2026-04-21T00:00:00',
|
|
duration: 'PT1H',
|
|
}),
|
|
];
|
|
expect(eventsOnDay(events, day)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('normalizeAllDayDuration', () => {
|
|
it('rounds up to whole days', () => {
|
|
expect(normalizeAllDayDuration('PT25H')).toBe('P2D');
|
|
});
|
|
it('returns at least P1D', () => {
|
|
expect(normalizeAllDayDuration('PT1H')).toBe('P1D');
|
|
});
|
|
it('returns undefined when input undefined', () => {
|
|
expect(normalizeAllDayDuration(undefined)).toBe(undefined);
|
|
});
|
|
});
|
|
|
|
describe('buildAllDayDuration', () => {
|
|
it('builds a one-day duration when same day', () => {
|
|
const start = new Date(2026, 3, 20);
|
|
expect(buildAllDayDuration(start, start)).toBe('P1D');
|
|
});
|
|
it('builds a multi-day duration inclusive of end', () => {
|
|
const start = new Date(2026, 3, 20);
|
|
const end = new Date(2026, 3, 22);
|
|
expect(buildAllDayDuration(start, end)).toBe('P3D');
|
|
});
|
|
});
|
|
|
|
describe('getCalendarColor', () => {
|
|
it('uses provided color when present', () => {
|
|
expect(getCalendarColor({ id: 'x', color: '#ff0000' })).toBe('#ff0000');
|
|
});
|
|
|
|
it('falls back to a deterministic palette color', () => {
|
|
const a = getCalendarColor({ id: 'cal-1' });
|
|
const b = getCalendarColor({ id: 'cal-1' });
|
|
expect(a).toBe(b);
|
|
expect(CALENDAR_COLOR_PALETTE).toContain(a);
|
|
});
|
|
|
|
it('returns the first palette color when undefined', () => {
|
|
expect(getCalendarColor(undefined)).toBe(CALENDAR_COLOR_PALETTE[0]);
|
|
});
|
|
});
|
|
|
|
describe('layoutOverlappingEvents', () => {
|
|
it('packs non-overlapping events into the same column', () => {
|
|
const day = new Date(2026, 3, 20);
|
|
const events = [
|
|
ev({
|
|
id: 'a', start: '2026-04-20T09:00:00',
|
|
utcStart: '2026-04-20T09:00:00Z', utcEnd: '2026-04-20T10:00:00Z',
|
|
duration: 'PT1H',
|
|
}),
|
|
ev({
|
|
id: 'b', start: '2026-04-20T10:00:00',
|
|
utcStart: '2026-04-20T10:00:00Z', utcEnd: '2026-04-20T11:00:00Z',
|
|
duration: 'PT1H',
|
|
}),
|
|
];
|
|
const layout = layoutOverlappingEvents(events, day);
|
|
expect(layout).toHaveLength(2);
|
|
expect(layout[0].column).toBe(0);
|
|
expect(layout[1].column).toBe(0);
|
|
expect(layout[0].totalColumns).toBe(1);
|
|
});
|
|
|
|
it('places overlapping events into separate columns', () => {
|
|
const day = new Date(2026, 3, 20);
|
|
const events = [
|
|
ev({
|
|
id: 'a', start: '2026-04-20T09:00:00',
|
|
utcStart: '2026-04-20T09:00:00Z', utcEnd: '2026-04-20T11:00:00Z',
|
|
duration: 'PT2H',
|
|
}),
|
|
ev({
|
|
id: 'b', start: '2026-04-20T10:00:00',
|
|
utcStart: '2026-04-20T10:00:00Z', utcEnd: '2026-04-20T12:00:00Z',
|
|
duration: 'PT2H',
|
|
}),
|
|
];
|
|
const layout = layoutOverlappingEvents(events, day);
|
|
expect(layout).toHaveLength(2);
|
|
const cols = layout.map((l) => l.column).sort();
|
|
expect(cols).toEqual([0, 1]);
|
|
expect(layout[0].totalColumns).toBe(2);
|
|
});
|
|
|
|
it('skips all-day events', () => {
|
|
const day = new Date(2026, 3, 20);
|
|
const events = [
|
|
ev({ id: 'all', showWithoutTime: true, duration: 'P1D' }),
|
|
];
|
|
expect(layoutOverlappingEvents(events, day)).toHaveLength(0);
|
|
});
|
|
});
|