feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants, scheduling messages, and inline calendar invitation banner in email viewer with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
getInvitationMethod,
|
||||
formatEventSummary,
|
||||
findParticipantByEmail,
|
||||
} from '../calendar-invitation';
|
||||
import type { Email, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
|
||||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'e1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 1024,
|
||||
receivedAt: '2026-02-17T10:00:00Z',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeParticipant(overrides: Partial<CalendarParticipant> = {}): CalendarParticipant {
|
||||
return {
|
||||
'@type': 'Participant',
|
||||
name: 'Test',
|
||||
email: 'test@example.com',
|
||||
description: null,
|
||||
sendTo: null,
|
||||
kind: 'individual',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
participationComment: null,
|
||||
expectReply: false,
|
||||
scheduleAgent: 'server',
|
||||
scheduleForceSend: false,
|
||||
scheduleId: null,
|
||||
scheduleSequence: 0,
|
||||
scheduleStatus: null,
|
||||
scheduleUpdated: null,
|
||||
invitedBy: null,
|
||||
delegatedTo: null,
|
||||
delegatedFrom: null,
|
||||
memberOf: null,
|
||||
locationId: null,
|
||||
language: null,
|
||||
links: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('findCalendarAttachment', () => {
|
||||
it('finds attachment by MIME type text/calendar', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b1', size: 500, type: 'text/calendar', name: 'invite.ics' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
const result = findCalendarAttachment(email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.blobId).toBe('b1');
|
||||
});
|
||||
|
||||
it('finds attachment by application/ics type', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b2', size: 500, type: 'application/ics', name: 'event.ics' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b2');
|
||||
});
|
||||
|
||||
it('finds attachment by .ics file extension', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b3', size: 500, type: 'application/octet-stream', name: 'meeting.ics' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b3');
|
||||
});
|
||||
|
||||
it('finds attachment by .ical file extension', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b4', size: 500, type: 'application/octet-stream', name: 'meeting.ical' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('b4');
|
||||
});
|
||||
|
||||
it('returns null when no calendar attachment exists', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'b5', size: 500, type: 'application/pdf', name: 'doc.pdf' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when attachments is undefined', () => {
|
||||
const email = makeEmail();
|
||||
expect(findCalendarAttachment(email)).toBeNull();
|
||||
});
|
||||
|
||||
it('detects text/calendar in textBody parts', () => {
|
||||
const email = makeEmail({
|
||||
textBody: [
|
||||
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
|
||||
],
|
||||
});
|
||||
const result = findCalendarAttachment(email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.blobId).toBe('tb1');
|
||||
});
|
||||
|
||||
it('prioritizes attachments over textBody', () => {
|
||||
const email = makeEmail({
|
||||
attachments: [
|
||||
{ partId: '1', blobId: 'att1', size: 500, type: 'text/calendar', name: 'invite.ics' },
|
||||
],
|
||||
textBody: [
|
||||
{ partId: 'p1', blobId: 'tb1', size: 300, type: 'text/calendar' },
|
||||
],
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(findCalendarAttachment(email)?.blobId).toBe('att1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvitationMethod', () => {
|
||||
it('detects cancel when status is cancelled', () => {
|
||||
expect(getInvitationMethod({ status: 'cancelled' })).toBe('cancel');
|
||||
});
|
||||
|
||||
it('detects request when participants have organizer role', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, name: 'Organizer' }),
|
||||
att: makeParticipant({ roles: { attendee: true }, name: 'Attendee' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('request');
|
||||
});
|
||||
|
||||
it('detects request with chair role', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { chair: true }, name: 'Chair' }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('request');
|
||||
});
|
||||
|
||||
it('returns unknown when no participants', () => {
|
||||
expect(getInvitationMethod({})).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown when participants have no organizer', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
att: makeParticipant({ roles: { attendee: true } }),
|
||||
},
|
||||
};
|
||||
expect(getInvitationMethod(event)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEventSummary', () => {
|
||||
it('extracts title from event', () => {
|
||||
const summary = formatEventSummary({ title: 'Team Sync' });
|
||||
expect(summary.title).toBe('Team Sync');
|
||||
});
|
||||
|
||||
it('extracts location from event', () => {
|
||||
const summary = formatEventSummary({
|
||||
locations: { loc1: { '@type': 'Location', name: 'Room A', description: null, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||
});
|
||||
expect(summary.location).toBe('Room A');
|
||||
});
|
||||
|
||||
it('extracts organizer info', () => {
|
||||
const summary = formatEventSummary({
|
||||
participants: {
|
||||
org: makeParticipant({ roles: { owner: true }, name: 'Alice', email: 'alice@example.com' }),
|
||||
att: makeParticipant({ roles: { attendee: true }, name: 'Bob' }),
|
||||
},
|
||||
});
|
||||
expect(summary.organizer).toBe('Alice');
|
||||
expect(summary.organizerEmail).toBe('alice@example.com');
|
||||
expect(summary.attendeeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('handles missing data gracefully', () => {
|
||||
const summary = formatEventSummary({});
|
||||
expect(summary.title).toBe('');
|
||||
expect(summary.start).toBeNull();
|
||||
expect(summary.end).toBeNull();
|
||||
expect(summary.location).toBeNull();
|
||||
expect(summary.organizer).toBeNull();
|
||||
expect(summary.attendeeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('computes end from start + duration', () => {
|
||||
const summary = formatEventSummary({
|
||||
start: '2026-02-17T10:00:00',
|
||||
duration: 'PT1H30M',
|
||||
});
|
||||
expect(summary.start).toBe('2026-02-17T10:00:00');
|
||||
expect(summary.end).toBeTruthy();
|
||||
const endDate = new Date(summary.end!);
|
||||
expect(endDate.getHours()).toBe(new Date('2026-02-17T10:00:00').getHours() + 1);
|
||||
expect(endDate.getMinutes()).toBe(new Date('2026-02-17T10:00:00').getMinutes() + 30);
|
||||
});
|
||||
|
||||
it('uses utcStart and utcEnd when available', () => {
|
||||
const summary = formatEventSummary({
|
||||
utcStart: '2026-02-17T15:00:00Z',
|
||||
utcEnd: '2026-02-17T16:00:00Z',
|
||||
start: '2026-02-17T10:00:00',
|
||||
});
|
||||
expect(summary.start).toBe('2026-02-17T15:00:00Z');
|
||||
expect(summary.end).toBe('2026-02-17T16:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findParticipantByEmail', () => {
|
||||
it('finds participant by direct email match', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'alice@example.com', name: 'Alice' }),
|
||||
},
|
||||
};
|
||||
const result = findParticipantByEmail(event, 'alice@example.com');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.id).toBe('p1');
|
||||
expect(result!.participant.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'Alice@Example.COM' }),
|
||||
},
|
||||
};
|
||||
expect(findParticipantByEmail(event, 'alice@example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('finds participant by sendTo mailto', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: '', sendTo: { imip: 'mailto:bob@example.com' } }),
|
||||
},
|
||||
};
|
||||
const result = findParticipantByEmail(event, 'bob@example.com');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.id).toBe('p1');
|
||||
});
|
||||
|
||||
it('returns null when no match', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'alice@example.com' }),
|
||||
},
|
||||
};
|
||||
expect(findParticipantByEmail(event, 'unknown@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with no participants', () => {
|
||||
expect(findParticipantByEmail({}, 'test@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with empty email', () => {
|
||||
const event: Partial<CalendarEvent> = {
|
||||
participants: {
|
||||
p1: makeParticipant({ email: 'alice@example.com' }),
|
||||
},
|
||||
};
|
||||
expect(findParticipantByEmail(event, '')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
getParticipantCount,
|
||||
buildParticipantMap,
|
||||
} from '@/lib/calendar-participants';
|
||||
|
||||
function makeEvent(participants: Record<string, Partial<CalendarParticipant>> | null = null): CalendarEvent {
|
||||
return {
|
||||
'@type': 'Event',
|
||||
id: 'ev1',
|
||||
uid: 'uid-ev1',
|
||||
calendarIds: { cal1: true },
|
||||
title: 'Test Event',
|
||||
description: '',
|
||||
descriptionContentType: 'text/plain',
|
||||
start: '2026-03-01T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'UTC',
|
||||
showWithoutTime: false,
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'busy',
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
recurrenceId: null,
|
||||
recurrenceIdTimeZone: null,
|
||||
recurrenceRules: null,
|
||||
recurrenceOverrides: null,
|
||||
excludedRecurrenceRules: null,
|
||||
useDefaultAlerts: false,
|
||||
alerts: null,
|
||||
locations: null,
|
||||
virtualLocations: null,
|
||||
links: null,
|
||||
relatedTo: null,
|
||||
utcStart: null,
|
||||
utcEnd: null,
|
||||
isDraft: false,
|
||||
isOrigin: true,
|
||||
sequence: 0,
|
||||
created: '2026-03-01T09:00:00Z',
|
||||
updated: '2026-03-01T09:00:00Z',
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
participants: participants as Record<string, CalendarParticipant> | null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
hideAttendees: false,
|
||||
};
|
||||
}
|
||||
|
||||
const orgParticipant: Partial<CalendarParticipant> = {
|
||||
'@type': 'Participant',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
roles: { owner: true, attendee: true },
|
||||
participationStatus: 'accepted',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: 'mailto:alice@example.com' },
|
||||
expectReply: false,
|
||||
kind: 'individual',
|
||||
};
|
||||
|
||||
const attendeeParticipant: Partial<CalendarParticipant> = {
|
||||
'@type': 'Participant',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
scheduleAgent: 'server',
|
||||
sendTo: { imip: 'mailto:bob@example.com' },
|
||||
expectReply: true,
|
||||
kind: 'individual',
|
||||
};
|
||||
|
||||
const acceptedAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Carol',
|
||||
email: 'carol@example.com',
|
||||
participationStatus: 'accepted',
|
||||
};
|
||||
|
||||
const declinedAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Dave',
|
||||
email: 'dave@example.com',
|
||||
participationStatus: 'declined',
|
||||
};
|
||||
|
||||
const tentativeAttendee: Partial<CalendarParticipant> = {
|
||||
...attendeeParticipant,
|
||||
name: 'Eve',
|
||||
email: 'eve@example.com',
|
||||
participationStatus: 'tentative',
|
||||
};
|
||||
|
||||
describe('isOrganizer', () => {
|
||||
it('returns true when user email matches organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with case-insensitive match', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['ALICE@EXAMPLE.COM'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when user is not organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['bob@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when user has multiple emails and one matches', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, ['other@example.com', 'alice@example.com'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when empty user emails', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(isOrganizer(event, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserParticipantId', () => {
|
||||
it('returns the participant ID for the user', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
expect(getUserParticipantId(event, ['bob@example.com'])).toBe('att1');
|
||||
});
|
||||
|
||||
it('returns organizer ID when user is organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserParticipantId(event, ['alice@example.com'])).toBe('org');
|
||||
});
|
||||
|
||||
it('returns null when user not found', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserParticipantId(event, ['unknown@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getUserParticipantId(event, ['alice@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
expect(getUserParticipantId(event, ['BOB@EXAMPLE.COM'])).toBe('att1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserStatus', () => {
|
||||
it('returns the participation status', () => {
|
||||
const event = makeEvent({ att1: attendeeParticipant });
|
||||
expect(getUserStatus(event, ['bob@example.com'])).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('returns accepted for organizer', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserStatus(event, ['alice@example.com'])).toBe('accepted');
|
||||
});
|
||||
|
||||
it('returns null when user not found', () => {
|
||||
const event = makeEvent({ org: orgParticipant });
|
||||
expect(getUserStatus(event, ['unknown@example.com'])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getUserStatus(event, ['alice@example.com'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParticipantList', () => {
|
||||
it('returns all participants as info objects', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
const list = getParticipantList(event);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.find(p => p.id === 'org')).toEqual({
|
||||
id: 'org',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
status: 'accepted',
|
||||
isOrganizer: true,
|
||||
});
|
||||
expect(list.find(p => p.id === 'att1')).toEqual({
|
||||
id: 'att1',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
status: 'needs-action',
|
||||
isOrganizer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getParticipantList(event)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults status to needs-action for missing status', () => {
|
||||
const event = makeEvent({
|
||||
att1: { ...attendeeParticipant, participationStatus: undefined },
|
||||
});
|
||||
const list = getParticipantList(event);
|
||||
expect(list[0].status).toBe('needs-action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusCounts', () => {
|
||||
it('counts statuses correctly', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: acceptedAttendee,
|
||||
att2: declinedAttendee,
|
||||
att3: tentativeAttendee,
|
||||
att4: attendeeParticipant,
|
||||
});
|
||||
const counts = getStatusCounts(event);
|
||||
expect(counts.accepted).toBe(2);
|
||||
expect(counts.declined).toBe(1);
|
||||
expect(counts.tentative).toBe(1);
|
||||
expect(counts['needs-action']).toBe(1);
|
||||
});
|
||||
|
||||
it('returns all zeros when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
const counts = getStatusCounts(event);
|
||||
expect(counts).toEqual({ accepted: 0, declined: 0, tentative: 0, 'needs-action': 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParticipantCount', () => {
|
||||
it('returns correct count', () => {
|
||||
const event = makeEvent({
|
||||
org: orgParticipant,
|
||||
att1: attendeeParticipant,
|
||||
});
|
||||
expect(getParticipantCount(event)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 when no participants', () => {
|
||||
const event = makeEvent(null);
|
||||
expect(getParticipantCount(event)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildParticipantMap', () => {
|
||||
it('creates organizer and attendees', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[
|
||||
{ name: 'Bob', email: 'bob@example.com' },
|
||||
{ name: 'Carol', email: 'carol@example.com' },
|
||||
]
|
||||
);
|
||||
|
||||
expect(Object.keys(map)).toHaveLength(3);
|
||||
|
||||
const org = map['organizer'];
|
||||
expect(org.name).toBe('Alice');
|
||||
expect(org.email).toBe('alice@example.com');
|
||||
expect(org.roles).toEqual({ owner: true, attendee: true });
|
||||
expect(org.participationStatus).toBe('accepted');
|
||||
expect(org.scheduleAgent).toBe('server');
|
||||
expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
|
||||
expect(org.expectReply).toBe(false);
|
||||
|
||||
const att0 = map['attendee-0'];
|
||||
expect(att0.name).toBe('Bob');
|
||||
expect(att0.email).toBe('bob@example.com');
|
||||
expect(att0.roles).toEqual({ attendee: true });
|
||||
expect(att0.participationStatus).toBe('needs-action');
|
||||
expect(att0.scheduleAgent).toBe('server');
|
||||
expect(att0.expectReply).toBe(true);
|
||||
|
||||
const att1 = map['attendee-1'];
|
||||
expect(att1.name).toBe('Carol');
|
||||
expect(att1.email).toBe('carol@example.com');
|
||||
});
|
||||
|
||||
it('creates only organizer when no attendees', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[]
|
||||
);
|
||||
expect(Object.keys(map)).toHaveLength(1);
|
||||
expect(map['organizer']).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets @type to Participant for all entries', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }]
|
||||
);
|
||||
Object.values(map).forEach(p => {
|
||||
expect(p['@type']).toBe('Participant');
|
||||
});
|
||||
});
|
||||
|
||||
it('sets kind to individual for all entries', () => {
|
||||
const map = buildParticipantMap(
|
||||
{ name: 'Alice', email: 'alice@example.com' },
|
||||
[{ name: 'Bob', email: 'bob@example.com' }]
|
||||
);
|
||||
Object.values(map).forEach(p => {
|
||||
expect(p.kind).toBe('individual');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getLuminance,
|
||||
isDarkColor,
|
||||
transformColorForDarkMode,
|
||||
transformBgColorForDarkMode,
|
||||
transformInlineStyles,
|
||||
} from '../color-transform';
|
||||
|
||||
@@ -149,7 +150,7 @@ describe('isDarkColor', () => {
|
||||
});
|
||||
|
||||
describe('transformColorForDarkMode', () => {
|
||||
it('should lighten very dark colors', () => {
|
||||
it('should brighten very dark colors', () => {
|
||||
const original = '#111111';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
@@ -160,11 +161,28 @@ describe('transformColorForDarkMode', () => {
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
|
||||
it('should transform #333333 to a lighter color', () => {
|
||||
it('should transform #333333 to a bright color', () => {
|
||||
const transformed = transformColorForDarkMode('#333333');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.4);
|
||||
expect(luminance).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('should produce readable results for Google Calendar grays', () => {
|
||||
const googleGrays = ['#757575', '#5f6368', '#70757a', '#3c4043'];
|
||||
for (const color of googleGrays) {
|
||||
const transformed = transformColorForDarkMode(color);
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.55);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve hue for colored text', () => {
|
||||
const transformed = transformColorForDarkMode('#1a73e8');
|
||||
const rgb = parseColor(transformed)!;
|
||||
expect(rgb.b).toBeGreaterThan(rgb.r);
|
||||
expect(rgb.b).toBeGreaterThan(rgb.g);
|
||||
});
|
||||
|
||||
it('should preserve already light colors', () => {
|
||||
@@ -197,7 +215,7 @@ describe('transformColorForDarkMode', () => {
|
||||
expect(transformColorForDarkMode('inherit')).toBe('inherit');
|
||||
});
|
||||
|
||||
it('should lighten medium darkness colors', () => {
|
||||
it('should brighten medium darkness colors', () => {
|
||||
const original = '#646463';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
@@ -207,6 +225,67 @@ describe('transformColorForDarkMode', () => {
|
||||
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
|
||||
it('should ensure minimum contrast for all dark text colors', () => {
|
||||
const darkTextColors = [
|
||||
'#000000', '#111111', '#222222', '#333333', '#444444',
|
||||
'#555555', '#666666', '#777777', '#888888',
|
||||
];
|
||||
for (const color of darkTextColors) {
|
||||
const transformed = transformColorForDarkMode(color);
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformBgColorForDarkMode', () => {
|
||||
it('should darken white backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#ffffff');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('should darken light gray backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#f8f9fa');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('should preserve already dark backgrounds', () => {
|
||||
const darkBgs = ['#111111', '#1a1a2e', '#0f172a'];
|
||||
for (const color of darkBgs) {
|
||||
expect(transformBgColorForDarkMode(color)).toBe(color);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve nearly transparent backgrounds', () => {
|
||||
const original = 'rgba(255, 255, 255, 0.05)';
|
||||
expect(transformBgColorForDarkMode(original)).toBe(original);
|
||||
});
|
||||
|
||||
it('should handle invalid colors gracefully', () => {
|
||||
expect(transformBgColorForDarkMode('invalid')).toBe('invalid');
|
||||
expect(transformBgColorForDarkMode('inherit')).toBe('inherit');
|
||||
});
|
||||
|
||||
it('should handle rgba backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('rgba(255, 255, 255, 0.9)');
|
||||
expect(transformed).toContain('rgba');
|
||||
expect(transformed).toContain('0.9');
|
||||
const rgb = parseColor(transformed)!;
|
||||
expect(rgb.r).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should moderately darken medium backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#e0e0e0');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformInlineStyles', () => {
|
||||
@@ -223,11 +302,23 @@ describe('transformInlineStyles', () => {
|
||||
expect(transformed).toContain('rgb(');
|
||||
});
|
||||
|
||||
it('should transform background-color property', () => {
|
||||
const original = 'background-color: #111111';
|
||||
it('should darken light background-color property', () => {
|
||||
const original = 'background-color: #ffffff';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background-color:');
|
||||
const colorMatch = transformed.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
expect(colorMatch).not.toBeNull();
|
||||
if (colorMatch) {
|
||||
const [, r] = colorMatch.map(Number);
|
||||
expect(r).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve dark background-color unchanged', () => {
|
||||
const original = 'background-color: #111111';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toBe(original);
|
||||
});
|
||||
|
||||
it('should preserve non-color properties', () => {
|
||||
@@ -238,7 +329,7 @@ describe('transformInlineStyles', () => {
|
||||
});
|
||||
|
||||
it('should handle multiple color properties', () => {
|
||||
const original = 'color: #111111; background-color: #222222; font-weight: bold';
|
||||
const original = 'color: #111111; background-color: #ffffff; font-weight: bold';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('color:');
|
||||
expect(transformed).toContain('background-color:');
|
||||
@@ -256,7 +347,7 @@ describe('transformInlineStyles', () => {
|
||||
expect(transformInlineStyles('invalid', 'dark')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should transform the James Clear email colors', () => {
|
||||
it('should brighten text colors for dark mode readability', () => {
|
||||
const original = 'color: #333333; font-family: Georgia; font-size: 16px';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
|
||||
@@ -268,14 +359,14 @@ describe('transformInlineStyles', () => {
|
||||
|
||||
if (colorMatch) {
|
||||
const [, r, g, b] = colorMatch.map(Number);
|
||||
expect(r).toBeGreaterThan(51);
|
||||
expect(g).toBeGreaterThan(51);
|
||||
expect(b).toBeGreaterThan(51);
|
||||
expect(r).toBeGreaterThan(180);
|
||||
expect(g).toBeGreaterThan(180);
|
||||
expect(b).toBeGreaterThan(180);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle background shorthand with color', () => {
|
||||
const original = 'background: #333333';
|
||||
it('should darken background shorthand with color', () => {
|
||||
const original = 'background: #ffffff';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background:');
|
||||
@@ -293,4 +384,22 @@ describe('transformInlineStyles', () => {
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('border-color:');
|
||||
});
|
||||
|
||||
it('should produce good contrast for Google Calendar emails', () => {
|
||||
const original = 'color: #5f6368; background-color: #f8f9fa';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
|
||||
const textMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const bgMatch = transformed.match(/background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
|
||||
expect(textMatch).not.toBeNull();
|
||||
expect(bgMatch).not.toBeNull();
|
||||
|
||||
if (textMatch && bgMatch) {
|
||||
const textLum = getLuminance(+textMatch[1], +textMatch[2], +textMatch[3]);
|
||||
const bgLum = getLuminance(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
|
||||
const contrast = (Math.max(textLum, bgLum) + 0.05) / (Math.min(textLum, bgLum) + 0.05);
|
||||
expect(contrast).toBeGreaterThan(4.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user