Phase 11: Per-Event Reminders (CAL-13/14, NOTIF-04/05/06) #19
@@ -310,6 +310,85 @@ describe('expandOccurrences', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reminderIsCustom — CR-01 (Phase 11 Plan 05)', () => {
|
||||||
|
it('absolute DATE-TIME VALARM trigger yields reminderIsCustom:true and reminderLeadMinutes:null', () => {
|
||||||
|
// Apple Calendar default all-day alarm style: TRIGGER;VALUE=DATE-TIME:...
|
||||||
|
// Before the fix, expand.ts maps {kind:'custom'} to null and does NOT set reminderIsCustom.
|
||||||
|
// This test MUST FAIL before the fix (no reminderIsCustom field on CalendarOccurrence).
|
||||||
|
const rawVevent = loadFixture('absolute-alarm.ics');
|
||||||
|
const windowStart = new Date('2026-12-01T00:00:00Z');
|
||||||
|
const windowEnd = new Date('2027-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
const occurrences = expandOccurrences(
|
||||||
|
rawVevent,
|
||||||
|
windowStart,
|
||||||
|
windowEnd,
|
||||||
|
1,
|
||||||
|
'My Calendar',
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'#4A90D9',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(occurrences.length).toBe(1);
|
||||||
|
const occ = occurrences[0];
|
||||||
|
// reminderIsCustom MUST be true (absolute DATE-TIME trigger)
|
||||||
|
expect((occ as Record<string, unknown>).reminderIsCustom).toBe(true);
|
||||||
|
// reminderLeadMinutes MUST be null (cannot reduce custom to a lead)
|
||||||
|
expect(occ.reminderLeadMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple VALARMs yield reminderIsCustom:true and reminderLeadMinutes:null', () => {
|
||||||
|
// Outlook/Apple sometimes produce two VALARMs — classifyValarms returns {kind:'custom'}
|
||||||
|
const rawVevent = loadFixture('multi-alarm.ics');
|
||||||
|
const windowStart = new Date('2026-12-01T00:00:00Z');
|
||||||
|
const windowEnd = new Date('2027-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
const occurrences = expandOccurrences(
|
||||||
|
rawVevent,
|
||||||
|
windowStart,
|
||||||
|
windowEnd,
|
||||||
|
1,
|
||||||
|
'My Calendar',
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'#4A90D9',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(occurrences.length).toBe(1);
|
||||||
|
const occ = occurrences[0];
|
||||||
|
expect((occ as Record<string, unknown>).reminderIsCustom).toBe(true);
|
||||||
|
expect(occ.reminderLeadMinutes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single relative preset VALARM yields reminderIsCustom:false', () => {
|
||||||
|
// A normal -PT15M DURATION trigger should NOT be flagged as custom
|
||||||
|
const rawVevent = loadFixture('weekly-dst.ics');
|
||||||
|
const windowStart = new Date('2026-03-01T00:00:00Z');
|
||||||
|
const windowEnd = new Date('2026-03-15T00:00:00Z');
|
||||||
|
|
||||||
|
const occurrences = expandOccurrences(
|
||||||
|
rawVevent,
|
||||||
|
windowStart,
|
||||||
|
windowEnd,
|
||||||
|
1,
|
||||||
|
'My Calendar',
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'#4A90D9',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// weekly-dst.ics has no VALARM — reminderIsCustom should be false
|
||||||
|
expect(occurrences.length).toBeGreaterThan(0);
|
||||||
|
for (const occ of occurrences) {
|
||||||
|
expect((occ as Record<string, unknown>).reminderIsCustom).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => {
|
describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => {
|
||||||
it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => {
|
it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => {
|
||||||
// This is the integration test that was missing. It takes the actual serializeTime output
|
// This is the integration test that was missing. It takes the actual serializeTime output
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Apple Calendar//NONSGML Version 1//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:absolute-alarm-test@familysync.test
|
||||||
|
DTSTART;VALUE=DATE:20261215
|
||||||
|
SUMMARY:Holiday Party
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:Reminder
|
||||||
|
TRIGGER;VALUE=DATE-TIME:20261214T140000Z
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Outlook//NONSGML Version 1//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:multi-alarm-test@familysync.test
|
||||||
|
DTSTART:20261215T100000Z
|
||||||
|
DTEND:20261215T110000Z
|
||||||
|
SUMMARY:Team Meeting
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:Reminder 1
|
||||||
|
TRIGGER:-PT15M
|
||||||
|
END:VALARM
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:Reminder 2
|
||||||
|
TRIGGER:-PT5M
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -1231,3 +1231,94 @@ describe('EventForm — Phase 11 reminder picker (Plan 04)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Phase 11 Plan 05: CR-01 custom alarm round-trip (TDD RED) ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture: edit occurrence with reminderIsCustom:true (absolute DATE-TIME or multi-VALARM).
|
||||||
|
* This field is added by Plan 05 — before the fix, CalendarOccurrence does not carry it,
|
||||||
|
* so the form cannot distinguish custom from no-reminder.
|
||||||
|
*/
|
||||||
|
const CUSTOM_ALARM_OCCURRENCE: CalendarOccurrence & { reminderIsCustom?: boolean } = {
|
||||||
|
id: 'custom-alarm-uid::2026-12-15',
|
||||||
|
uid: 'custom-alarm-uid',
|
||||||
|
calendarId: 1,
|
||||||
|
calendarName: 'My Calendar',
|
||||||
|
ownerUserId: 1,
|
||||||
|
ownerName: 'Alice',
|
||||||
|
color: '#4A90D9',
|
||||||
|
isShared: false,
|
||||||
|
title: 'Holiday Party',
|
||||||
|
start: '2026-12-15',
|
||||||
|
end: '2026-12-16',
|
||||||
|
allDay: true,
|
||||||
|
location: null,
|
||||||
|
description: null,
|
||||||
|
hasRrule: false,
|
||||||
|
reminderLeadMinutes: null, // custom alarms cannot be reduced to a lead
|
||||||
|
reminderIsCustom: true, // CR-01 new field: signals absolute/multi alarm
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EventForm — Phase 11 Plan 05 CR-01: custom alarm round-trip', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockEventFormOpen = true;
|
||||||
|
mockEventFormMode = 'create';
|
||||||
|
mockEventFormUid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CR-01: edit with reminderIsCustom=true initializes picker to __custom__ (not __none__)', () => {
|
||||||
|
// Before the fix: occurrence.reminderIsCustom does not exist; deriveReminderValue(null, ...)
|
||||||
|
// returns '__none__'. This test MUST FAIL before the fix.
|
||||||
|
renderForm({
|
||||||
|
mode: 'edit',
|
||||||
|
uid: 'custom-alarm-uid',
|
||||||
|
eventOccurrence: CUSTOM_ALARM_OCCURRENCE as CalendarOccurrence,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement;
|
||||||
|
expect(reminderSelect).not.toBeNull();
|
||||||
|
// Must be __custom__, NOT __none__ (the pre-fix incorrect value)
|
||||||
|
expect(reminderSelect.value).toBe('__custom__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CR-01: Custom (kept) disabled option is visible when reminderIsCustom=true', () => {
|
||||||
|
renderForm({
|
||||||
|
mode: 'edit',
|
||||||
|
uid: 'custom-alarm-uid',
|
||||||
|
eventOccurrence: CUSTOM_ALARM_OCCURRENCE as CalendarOccurrence,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The read-only "Custom (kept)" option must be visible
|
||||||
|
const customOption = screen.queryByText('Custom (kept)');
|
||||||
|
expect(customOption).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CR-01: submitting in __custom__ state omits reminderLeadMinutes from payload (D-08 preserve)', async () => {
|
||||||
|
// The critical data-loss test: edit a custom-alarm event, change the title, save.
|
||||||
|
// The payload must NOT include reminderLeadMinutes (field absent = preserve VALARM).
|
||||||
|
renderForm({
|
||||||
|
mode: 'edit',
|
||||||
|
uid: 'custom-alarm-uid',
|
||||||
|
eventOccurrence: CUSTOM_ALARM_OCCURRENCE as CalendarOccurrence,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change the title to simulate a real edit
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Event title'), {
|
||||||
|
target: { value: 'Holiday Party (Updated)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Save Changes'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateEvent).toHaveBeenCalled();
|
||||||
|
const callPayload = mockUpdateEvent.mock.calls[0][1] as Record<string, unknown>;
|
||||||
|
// MUST be absent: the presence of reminderLeadMinutes:null would cause the outbox
|
||||||
|
// worker to clear the VALARM — the CR-01 data-loss bug.
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(callPayload, 'reminderLeadMinutes')).toBe(
|
||||||
|
false,
|
||||||
|
'reminderLeadMinutes must be absent from payload when alarm is custom (D-08 preserve path)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user