Phase 11: Per-Event Reminders (CAL-13/14, NOTIF-04/05/06) #19
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* RED test scaffold: broker/vevent.ts — VEVENT builder (CAL-04, CAL-07)
|
* RED test scaffold: broker/vevent.ts — VEVENT builder (CAL-04, CAL-07, CAL-13, CAL-14)
|
||||||
*
|
*
|
||||||
* Behaviors under test:
|
* Behaviors under test:
|
||||||
* 1. buildVeventString produces a VCALENDAR with a VEVENT for a timed event
|
* 1. buildVeventString produces a VCALENDAR with a VEVENT for a timed event
|
||||||
@@ -7,17 +7,22 @@
|
|||||||
* 2. buildVeventString produces a VCALENDAR with DTSTART as a DATE value
|
* 2. buildVeventString produces a VCALENDAR with DTSTART as a DATE value
|
||||||
* (no time component, no TZID) for all-day events (D-13, Pitfall 3)
|
* (no time component, no TZID) for all-day events (D-13, Pitfall 3)
|
||||||
* 3. buildVeventString with rruleString produces a VCALENDAR with an RRULE property (CAL-07)
|
* 3. buildVeventString with rruleString produces a VCALENDAR with an RRULE property (CAL-07)
|
||||||
*
|
* 4. buildTimedValarm / buildAllDayValarm VALARM builders (CAL-13, Phase 11 Plan 01)
|
||||||
* These tests FAIL (RED) because broker/vevent.ts does not exist yet.
|
* 5. classifyValarms / extractValarms (CAL-14, Phase 11 Plan 01)
|
||||||
* They will turn GREEN in Plan 03-02 when the implementation is added.
|
* 6. computeAlertInstantUtc DST-correct 9 AM-local→UTC (NOTIF-06, Phase 11 Plan 01)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import ICAL from 'ical.js';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
// This import fails (RED) — broker/vevent.ts does not exist yet.
|
import {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
buildVeventString,
|
||||||
// @ts-ignore intentional RED import
|
buildTimedValarm,
|
||||||
import { buildVeventString } from '../../src/broker/vevent.js';
|
buildAllDayValarm,
|
||||||
|
classifyValarms,
|
||||||
|
extractValarms,
|
||||||
|
computeAlertInstantUtc,
|
||||||
|
} from '../../src/broker/vevent.js';
|
||||||
|
|
||||||
describe('buildVeventString', () => {
|
describe('buildVeventString', () => {
|
||||||
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
|
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
|
||||||
@@ -196,3 +201,264 @@ describe('buildVeventString — D-13 form-parsed contract', () => {
|
|||||||
expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]);
|
expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Phase 11 Plan 01: VALARM builders (CAL-13) ──────────────────────────────
|
||||||
|
|
||||||
|
describe('buildTimedValarm', () => {
|
||||||
|
it('returns a VALARM component with TRIGGER:-PT30M and no VALUE=TEXT', () => {
|
||||||
|
const valarm = buildTimedValarm(30);
|
||||||
|
const ics = valarm.toString();
|
||||||
|
expect(ics).toContain('TRIGGER:-PT30M');
|
||||||
|
expect(ics).not.toContain('VALUE=TEXT');
|
||||||
|
expect(ics).toContain('ACTION:DISPLAY');
|
||||||
|
expect(ics).toContain('DESCRIPTION:Reminder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a VALARM with TRIGGER:-PT2H (or -PT120M) for 120 minutes and no VALUE=TEXT', () => {
|
||||||
|
const valarm = buildTimedValarm(120);
|
||||||
|
const ics = valarm.toString();
|
||||||
|
// ical.js may emit -PT2H or -PT120M — either is RFC-valid
|
||||||
|
expect(ics).toMatch(/TRIGGER:-P(?:T2H|T120M)/);
|
||||||
|
expect(ics).not.toContain('VALUE=TEXT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAllDayValarm', () => {
|
||||||
|
it('returns a VALARM with absolute DATE-TIME trigger in UTC (Z suffix, no DURATION)', () => {
|
||||||
|
const alertInstant = new Date('2026-06-14T13:00:00Z');
|
||||||
|
const valarm = buildAllDayValarm(alertInstant);
|
||||||
|
const ics = valarm.toString();
|
||||||
|
// Must contain the absolute UTC datetime
|
||||||
|
expect(ics).toContain('20260614T130000Z');
|
||||||
|
// Must carry VALUE=DATE-TIME
|
||||||
|
expect(ics).toContain('VALUE=DATE-TIME');
|
||||||
|
// Must NOT be a DURATION trigger
|
||||||
|
expect(ics).not.toMatch(/TRIGGER:-PT/);
|
||||||
|
expect(ics).toContain('ACTION:DISPLAY');
|
||||||
|
expect(ics).toContain('DESCRIPTION:Reminder');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildVeventString — VALARM emission (CAL-13)', () => {
|
||||||
|
it('emits exactly one VALARM with TRIGGER:-PT15M for a timed event with reminderLeadMinutes=15', () => {
|
||||||
|
const result = buildVeventString({
|
||||||
|
summary: 'Meeting',
|
||||||
|
allDay: false,
|
||||||
|
dtstart: new Date('2026-06-15T14:00:00Z'),
|
||||||
|
dtend: new Date('2026-06-15T15:00:00Z'),
|
||||||
|
reminderLeadMinutes: 15,
|
||||||
|
});
|
||||||
|
expect(result.icsString).toContain('BEGIN:VALARM');
|
||||||
|
expect(result.icsString).toContain('TRIGGER:-PT15M');
|
||||||
|
expect(result.icsString).not.toContain('VALUE=TEXT');
|
||||||
|
// Only one VALARM block
|
||||||
|
const count = (result.icsString.match(/BEGIN:VALARM/g) ?? []).length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits NO VALARM for a timed event with reminderLeadMinutes=0 (D-06: 0 on timed = None)', () => {
|
||||||
|
const result = buildVeventString({
|
||||||
|
summary: 'Meeting',
|
||||||
|
allDay: false,
|
||||||
|
dtstart: new Date('2026-06-15T14:00:00Z'),
|
||||||
|
dtend: new Date('2026-06-15T15:00:00Z'),
|
||||||
|
reminderLeadMinutes: 0,
|
||||||
|
});
|
||||||
|
expect(result.icsString).not.toContain('BEGIN:VALARM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits one VALARM with absolute DATE-TIME trigger for an all-day event with reminderLeadMinutes=1440', () => {
|
||||||
|
const alertInstant = new Date('2026-06-14T13:00:00Z');
|
||||||
|
const result = buildVeventString({
|
||||||
|
summary: 'Anniversary',
|
||||||
|
allDay: true,
|
||||||
|
dtstart: '2026-06-15',
|
||||||
|
dtend: '2026-06-15',
|
||||||
|
reminderLeadMinutes: 1440,
|
||||||
|
allDayAlertInstantUtc: alertInstant,
|
||||||
|
});
|
||||||
|
expect(result.icsString).toContain('BEGIN:VALARM');
|
||||||
|
expect(result.icsString).toContain('VALUE=DATE-TIME');
|
||||||
|
expect(result.icsString).toContain('20260614T130000Z');
|
||||||
|
expect(result.icsString).not.toContain('VALUE=TEXT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits NO VALARM when reminderLeadMinutes is null', () => {
|
||||||
|
const result = buildVeventString({
|
||||||
|
summary: 'No reminder event',
|
||||||
|
allDay: false,
|
||||||
|
dtstart: new Date('2026-06-15T14:00:00Z'),
|
||||||
|
dtend: new Date('2026-06-15T15:00:00Z'),
|
||||||
|
reminderLeadMinutes: null,
|
||||||
|
});
|
||||||
|
expect(result.icsString).not.toContain('BEGIN:VALARM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the supplied VALARM verbatim via params.valarms (preserve path) even when reminderLeadMinutes is also set', () => {
|
||||||
|
const preservedValarm = buildTimedValarm(60);
|
||||||
|
const result = buildVeventString({
|
||||||
|
summary: 'Preserved alarm',
|
||||||
|
allDay: false,
|
||||||
|
dtstart: new Date('2026-06-15T14:00:00Z'),
|
||||||
|
dtend: new Date('2026-06-15T15:00:00Z'),
|
||||||
|
valarms: [preservedValarm],
|
||||||
|
reminderLeadMinutes: 15, // should be ignored — preserve wins
|
||||||
|
});
|
||||||
|
// The preserved 60-min alarm is present
|
||||||
|
expect(result.icsString).toContain('TRIGGER:-PT1H');
|
||||||
|
// The 15-min alarm is NOT additionally synthesized
|
||||||
|
expect(result.icsString).not.toContain('TRIGGER:-PT15M');
|
||||||
|
// Exactly one VALARM block
|
||||||
|
const count = (result.icsString.match(/BEGIN:VALARM/g) ?? []).length;
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Phase 11 Plan 01: VALARM classifier + extractor (CAL-14) ────────────────
|
||||||
|
|
||||||
|
// Minimal ICS shell wrapping a VEVENT — used in classifier tests
|
||||||
|
function makeIcs(veventBody: string): string {
|
||||||
|
return [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Test//Test//EN',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:test@test',
|
||||||
|
'SUMMARY:Test',
|
||||||
|
'DTSTART:20260615T140000Z',
|
||||||
|
'DTEND:20260615T150000Z',
|
||||||
|
veventBody,
|
||||||
|
'END:VEVENT',
|
||||||
|
'END:VCALENDAR',
|
||||||
|
].join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('classifyValarms', () => {
|
||||||
|
it('returns { kind: "none" } for an ICS with no VALARM', () => {
|
||||||
|
expect(classifyValarms(makeIcs(''))).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { kind: "preset", leadMinutes: 15 } for a single TRIGGER:-PT15M', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
'BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
|
||||||
|
);
|
||||||
|
expect(classifyValarms(ics)).toEqual({ kind: 'preset', leadMinutes: 15 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { kind: "offlist", leadMinutes: 45 } for a single TRIGGER:-PT45M', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
'BEGIN:VALARM\r\nTRIGGER:-PT45M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
|
||||||
|
);
|
||||||
|
expect(classifyValarms(ics)).toEqual({ kind: 'offlist', leadMinutes: 45 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { kind: "custom" } for an absolute DATE-TIME trigger', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
'BEGIN:VALARM\r\nTRIGGER;VALUE=DATE-TIME:20260615T130000Z\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
|
||||||
|
);
|
||||||
|
expect(classifyValarms(ics)).toEqual({ kind: 'custom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { kind: "custom" } for two VALARM blocks', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
[
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'TRIGGER:-PT15M',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Reminder',
|
||||||
|
'END:VALARM',
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'TRIGGER:-PT30M',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:Reminder',
|
||||||
|
'END:VALARM',
|
||||||
|
].join('\r\n'),
|
||||||
|
);
|
||||||
|
expect(classifyValarms(ics)).toEqual({ kind: 'custom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { kind: "none" } for unparseable input (safe default)', () => {
|
||||||
|
expect(classifyValarms('not valid ics')).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies 1440 (1 day) as preset', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
'BEGIN:VALARM\r\nTRIGGER:-P1D\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
|
||||||
|
);
|
||||||
|
const result = classifyValarms(ics);
|
||||||
|
expect(result.kind).toBe('preset');
|
||||||
|
if (result.kind === 'preset') expect(result.leadMinutes).toBe(1440);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies 10080 (1 week) as preset', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
'BEGIN:VALARM\r\nTRIGGER:-P7D\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
|
||||||
|
);
|
||||||
|
const result = classifyValarms(ics);
|
||||||
|
expect(result.kind).toBe('preset');
|
||||||
|
if (result.kind === 'preset') expect(result.leadMinutes).toBe(10080);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractValarms', () => {
|
||||||
|
it('returns an array of length 1 for an ICS with one VALARM', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
'BEGIN:VALARM\r\nTRIGGER:-PT30M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
|
||||||
|
);
|
||||||
|
const result = extractValarms(ics);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toBeInstanceOf(ICAL.Component);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array for an ICS with no VALARM', () => {
|
||||||
|
expect(extractValarms(makeIcs(''))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array for garbage input', () => {
|
||||||
|
expect(extractValarms('garbage ics')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a live ICAL.Component re-attachable via addSubcomponent (round-trip)', () => {
|
||||||
|
const ics = makeIcs(
|
||||||
|
'BEGIN:VALARM\r\nTRIGGER:-PT60M\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nEND:VALARM',
|
||||||
|
);
|
||||||
|
const [alarm] = extractValarms(ics);
|
||||||
|
// Should be usable as a component — attach it to a new vevent
|
||||||
|
const vevent = new ICAL.Component('vevent');
|
||||||
|
expect(() => vevent.addSubcomponent(alarm)).not.toThrow();
|
||||||
|
expect(vevent.toString()).toContain('TRIGGER:-PT1H');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Phase 11 Plan 01: computeAlertInstantUtc (NOTIF-06) ─────────────────────
|
||||||
|
|
||||||
|
describe('computeAlertInstantUtc', () => {
|
||||||
|
it('same day (leadDays=0), America/New_York summer → 2026-06-15T13:00:00.000Z (9 AM EDT = UTC-4)', () => {
|
||||||
|
const result = computeAlertInstantUtc('2026-06-15', 0, 'America/New_York');
|
||||||
|
expect(result.toISOString()).toBe('2026-06-15T13:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1 day before (leadDays=1), America/New_York summer → 2026-06-14T13:00:00.000Z', () => {
|
||||||
|
const result = computeAlertInstantUtc('2026-06-15', 1, 'America/New_York');
|
||||||
|
expect(result.toISOString()).toBe('2026-06-14T13:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('winter (leadDays=0), America/New_York → 2026-01-15T14:00:00.000Z (9 AM EST = UTC-5)', () => {
|
||||||
|
const result = computeAlertInstantUtc('2026-01-15', 0, 'America/New_York');
|
||||||
|
expect(result.toISOString()).toBe('2026-01-15T14:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spring-forward DST (US 2026-03-08) — 9 AM local uses the post-transition offset (-4 → UTC+13:00:00Z)', () => {
|
||||||
|
// 2026-03-08 is the spring-forward day in the US; after 2 AM clocks move +1h
|
||||||
|
// 9 AM on 2026-03-08 in America/New_York = 13:00 UTC (EDT, UTC-4)
|
||||||
|
const result = computeAlertInstantUtc('2026-03-08', 0, 'America/New_York');
|
||||||
|
expect(result.toISOString()).toBe('2026-03-08T13:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fall-back DST (US 2026-11-01) — 9 AM local uses the post-transition offset (-5 → UTC+14:00:00Z)', () => {
|
||||||
|
// 2026-11-01 is the fall-back day; clocks move back 1h at 2 AM
|
||||||
|
// 9 AM on 2026-11-01 in America/New_York = 14:00 UTC (EST, UTC-5)
|
||||||
|
const result = computeAlertInstantUtc('2026-11-01', 0, 'America/New_York');
|
||||||
|
expect(result.toISOString()).toBe('2026-11-01T14:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user