/** * EventForm tests — Plan 03-12 (TDD RED → GREEN, gap closure) * * Original coverage (Plan 03-05): * - All required fields render (title, all-day, start/end date/time, recurrence, location, description) * - All-day toggle hides time inputs; un-toggling shows them * - Calendar picker absent when writable-calendars returns 1 calendar (D-02) * - Calendar picker present when writable-calendars returns 2 calendars (D-02) * - Empty title shows "Title is required" error * - End-before-start shows "End time must be after start" error * - Submit in create mode calls createEvent mutation * - Submit in edit mode calls updateEvent mutation * - Escape key closes the form * - Backdrop click closes the form * - role="dialog" aria-modal="true" * - No dangerouslySetInnerHTML usage (security) * * Added in Plan 03-12 (gap closure): * - WR-03: edit form re-populates when occurrence arrives in cache after form open * - WR-03: editing a recurring occurrence preselects its recurrence preset (not 'none') * - WR-05: parseDateTime is zone-consistent (TZ-pinned to UTC for deterministic assertion) * - IN-03: todayIso is exported from calendarStore.ts (single source of truth) * - WR-07: Tab/Shift+Tab focus trap cycles within the dialog * - PWA-01/PWA-02: install asset files exist (verified below + in SUMMARY) */ // TZ=UTC is set via vitest.config.ts env block so every Date in this file uses UTC wall clock. // Approach: vitest.config.ts env: { TZ: 'UTC' } (see note in WR-05 test block below). import 'temporal-polyfill/global'; import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // ── Module mocks ────────────────────────────────────────────────────────────── // vi.hoisted() is required for variables used inside vi.mock() factory functions // to avoid TDZ (temporal dead zone) issues — decision D-03-04-hoisting. const { mockSetEventForm, mockSetLastSyncedUid, mockCreateEvent, mockUpdateEvent, mockFetchWritableCalendars, } = vi.hoisted(() => ({ mockSetEventForm: vi.fn(), mockSetLastSyncedUid: vi.fn(), mockCreateEvent: vi.fn().mockResolvedValue({ uid: 'new-uid-123' }), mockUpdateEvent: vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }), mockFetchWritableCalendars: vi.fn(), })); let mockEventFormOpen = true; let mockEventFormMode: 'create' | 'edit' = 'create'; let mockEventFormUid: string | null = null; vi.mock('../store/calendarStore.js', () => ({ useCalendarStore: vi.fn((selector?: (s: Record) => unknown) => { const state = { eventFormOpen: mockEventFormOpen, eventFormMode: mockEventFormMode, eventFormUid: mockEventFormUid, setEventForm: mockSetEventForm, setLastSyncedUid: mockSetLastSyncedUid, }; // Support both selector form and plain call form if (typeof selector === 'function') return selector(state); return state; }), // IN-03: todayIso is now exported from calendarStore (gap closure); mock it here // so EventForm can import it without errors. Returns today as YYYY-MM-DD. todayIso: () => new Date().toISOString().slice(0, 10), })); vi.mock('../api/client.js', () => ({ createEvent: mockCreateEvent, updateEvent: mockUpdateEvent, fetchWritableCalendars: mockFetchWritableCalendars, })); // ── Fixtures ─────────────────────────────────────────────────────────────────── import type { CalendarOccurrence } from '../api/client.js'; import type { WritableCalendar } from '../api/client.js'; const ONE_CALENDAR: WritableCalendar[] = [ { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false, }, ]; const TWO_CALENDARS: WritableCalendar[] = [ { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false, }, { url: 'https://caldav.fastmail.com/cal2', displayName: 'Family', color: '#F25C7A', isShared: true, }, ]; const EDIT_OCCURRENCE: CalendarOccurrence = { id: 'edit-uid-456::2026-06-15T10:00:00', uid: 'edit-uid-456', calendarId: 1, calendarName: 'My Calendar', ownerUserId: 1, ownerName: 'Alice', color: '#4A90D9', isShared: false, title: 'Existing Meeting', start: '2026-06-15T10:00:00-04:00', end: '2026-06-15T11:00:00-04:00', allDay: false, location: 'Office', description: 'Weekly sync', hasRrule: false, reminderLeadMinutes: null, reminderIsCustom: false, }; // ── Import component (after mocks) ──────────────────────────────────────────── import { EventForm } from './EventForm.js'; import { useCalendarStore } from '../store/calendarStore.js'; // ── Helpers ─────────────────────────────────────────────────────────────────── function renderForm( options: { mode?: 'create' | 'edit'; uid?: string | null; calendars?: WritableCalendar[]; eventOccurrence?: CalendarOccurrence; } = {}, ) { const { mode = 'create', uid = null, calendars = ONE_CALENDAR, eventOccurrence } = options; mockEventFormOpen = true; mockEventFormMode = mode; mockEventFormUid = uid; (useCalendarStore as unknown as ReturnType).mockImplementation( (selector?: (s: Record) => unknown) => { const state = { eventFormOpen: true, eventFormMode: mode, eventFormUid: uid, setEventForm: mockSetEventForm, setLastSyncedUid: mockSetLastSyncedUid, }; if (typeof selector === 'function') return selector(state); return state; }, ); mockFetchWritableCalendars.mockResolvedValue(calendars); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); // Pre-populate the events cache for edit mode pre-population if (eventOccurrence) { client.setQueryData(['events'], { occurrences: [eventOccurrence] }); } // Pre-populate writable calendars cache client.setQueryData(['writableCalendars'], calendars); return render( , ); } // ── Tests ───────────────────────────────────────────────────────────────────── describe('EventForm', () => { beforeEach(() => { vi.clearAllMocks(); mockEventFormOpen = true; mockEventFormMode = 'create'; mockEventFormUid = null; }); // ── Role and accessibility ───────────────────────────────────────────────── it('has role="dialog" and aria-modal="true"', () => { renderForm(); const dialog = screen.getByRole('dialog'); expect(dialog).toBeDefined(); expect(dialog.getAttribute('aria-modal')).toBe('true'); }); it('has aria-label "New Event" in create mode', () => { renderForm({ mode: 'create' }); const dialog = screen.getByRole('dialog'); expect(dialog.getAttribute('aria-label')).toBe('New Event'); }); it('has aria-label "Edit Event" in edit mode', () => { renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: EDIT_OCCURRENCE }); const dialog = screen.getByRole('dialog'); expect(dialog.getAttribute('aria-label')).toBe('Edit Event'); }); // ── Required fields ──────────────────────────────────────────────────────── it('renders title input with placeholder "Event title"', () => { renderForm(); const titleInput = screen.getByPlaceholderText('Event title'); expect(titleInput).toBeDefined(); }); it('renders All-day toggle', () => { renderForm(); expect(screen.getByText('All day')).toBeDefined(); }); it('renders start date input', () => { renderForm(); // Start date input should exist const dateInputs = document.querySelectorAll('input[type="date"]'); expect(dateInputs.length).toBeGreaterThanOrEqual(1); }); it('renders recurrence picker labeled "Repeat"', () => { renderForm(); expect(screen.getByText('Repeat')).toBeDefined(); }); it('renders recurrence options: None, Daily, Weekly, Monthly, Yearly', () => { renderForm(); // Use getAllByText since "None" now appears in both the recurrence picker and the // reminder picker (Phase 11 — two