/**
* CalendarShell render smoke test — CAL-03
*
* Guards:
* - Pitfall 4: hydrateEvents is called before eventsService.set(); no ISO-string rejection
* - Pitfall 6: @schedule-x/react@4.1.0 ↔ @schedule-x/calendar@4.6.0 compatibility —
* useCalendarApp and ScheduleXCalendar are called without throwing
*
* Strategy: mock @schedule-x/react (Preact internals are not jsdom-compatible) so the test
* focuses on the CalendarShell data pipeline (fetchMe → fetchEvents → hydrateEvents →
* eventsService.set), not on Schedule-X's internal DOM rendering.
* hydrateEvents is spied on to verify the all-day PlainDate path and the timed ZonedDateTime
* path are both invoked without error (the Temporal hydration contracts from hydrateEvents.test.ts).
*/
import 'temporal-polyfill/global'
import React from 'react'
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// window.matchMedia is polyfilled in src/test-setup.ts (loaded via vitest.config setupFiles)
// ── Module mocks (hoisted by Vitest) ────────────────────────────────────────
// Mock @schedule-x/react to avoid Preact signal DOM side-effects under jsdom.
// The real useCalendarApp + ScheduleXCalendar are structurally tested; this mock
// verifies the adapter import resolves (Pitfall 6: adapter/core compatibility).
vi.mock('@schedule-x/react', () => ({
useCalendarApp: vi.fn((_config: unknown, _plugins?: unknown[]) => ({
// Minimal CalendarApp stub that keeps CalendarShell happy
})),
ScheduleXCalendar: ({ calendarApp }: { calendarApp: unknown }) => (
),
}))
// Mock @schedule-x/events-service to capture .set() calls
const mockEventsServiceSet = vi.fn()
vi.mock('@schedule-x/events-service', () => ({
createEventsServicePlugin: vi.fn(() => ({
name: 'events-service',
set: mockEventsServiceSet,
get: vi.fn(),
getAll: vi.fn(() => []),
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
})),
}))
// Mock @schedule-x/event-modal
vi.mock('@schedule-x/event-modal', () => ({
createEventModalPlugin: vi.fn(() => ({
name: 'event-modal',
})),
}))
// Mock @schedule-x/calendar-controls so CalendarShell can create the plugin
vi.mock('@schedule-x/calendar-controls', () => ({
createCalendarControlsPlugin: vi.fn(() => ({
name: 'calendarControls',
beforeRender: vi.fn(),
onRender: vi.fn(),
setDate: vi.fn(),
setView: vi.fn(),
getDate: vi.fn(() => Temporal.Now.plainDateISO()),
getView: vi.fn(() => 'month-grid'),
setFirstDayOfWeek: vi.fn(),
setLocale: vi.fn(),
setViews: vi.fn(),
setDayBoundaries: vi.fn(),
setWeekOptions: vi.fn(),
setCalendars: vi.fn(),
setMinDate: vi.fn(),
setMaxDate: vi.fn(),
setMonthGridOptions: vi.fn(),
setTimezone: vi.fn(),
setResources: vi.fn(),
getFirstDayOfWeek: vi.fn(),
getLocale: vi.fn(),
getViews: vi.fn(() => []),
getDayBoundaries: vi.fn(),
getWeekOptions: vi.fn(),
getCalendars: vi.fn(() => ({})),
getMinDate: vi.fn(),
getMaxDate: vi.fn(),
getMonthGridOptions: vi.fn(),
getResources: vi.fn(() => []),
getRange: vi.fn(() => null),
})),
}))
// Mock the API client
vi.mock('../api/client.js', () => ({
fetchMe: vi.fn(),
fetchEvents: vi.fn(),
}))
// ── Spy on hydrateEvents (must be after vi.mock but before imports use it) ──
vi.mock('../lib/hydrateEvents.js', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
hydrateEvents: vi.fn(actual.hydrateEvents), // spy wrapping real implementation
}
})
// ── Imports (after mocks are in place) ──────────────────────────────────────
import { fetchMe, fetchEvents } from '../api/client.js'
import { hydrateEvents } from '../lib/hydrateEvents.js'
import { CalendarShell } from './CalendarShell.js'
// ── Fixtures ─────────────────────────────────────────────────────────────────
const TIMED_OCCURRENCE = {
id: 'timed-uid::2026-06-15T10:00:00',
uid: 'timed-uid',
calendarId: 1,
calendarName: 'Calendar',
ownerUserId: 1,
color: '#4A90D9',
isShared: false,
title: 'Team Standup',
start: '2026-06-15T10:00:00-04:00[America/New_York]',
end: '2026-06-15T10:30:00-04:00[America/New_York]',
allDay: false,
location: null,
description: null,
}
const ALLDAY_OCCURRENCE = {
id: 'allday-uid::2026-06-20',
uid: 'allday-uid',
calendarId: 2,
calendarName: 'Shared Calendar',
ownerUserId: 1,
color: '#F25C7A',
isShared: true,
title: 'Birthday',
start: '2026-06-20',
end: '2026-06-20',
allDay: true,
location: null,
description: null,
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
// Keep staleTime at 0 so data is fetched immediately in tests
staleTime: 0,
},
},
})
}
function renderWithClient(ui: React.ReactElement) {
const client = makeQueryClient()
return render(
{ui},
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('CalendarShell — CAL-03 render smoke', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default mock responses
;(fetchMe as Mock).mockResolvedValue({
user: { id: 1, displayName: 'Lucas', color: '#4A90D9' },
})
;(fetchEvents as Mock).mockResolvedValue({
occurrences: [TIMED_OCCURRENCE, ALLDAY_OCCURRENCE],
})
})
it('renders without throwing with all four views configured (CAL-03 smoke)', () => {
// Validates @schedule-x/react@4.1.0 ↔ @schedule-x/calendar@4.6.0 import compatibility (Pitfall 6)
expect(() => renderWithClient()).not.toThrow()
})
it('mounts the ScheduleXCalendar with a non-null calendarApp after data loads', async () => {
renderWithClient()
// CalendarShell shows SkeletonCalendar during loading; wait for data to resolve
const calEl = await screen.findByTestId('schedule-x-calendar')
expect(calEl).toBeDefined()
expect(calEl.getAttribute('data-has-app')).toBe('true')
})
it('calls hydrateEvents with both timed and all-day occurrences and passes them to eventsService', async () => {
renderWithClient()
await waitFor(() => {
expect(hydrateEvents as Mock).toHaveBeenCalled()
})
const callArgs = (hydrateEvents as Mock).mock.calls[0][0] as typeof TIMED_OCCURRENCE[]
const ids = callArgs.map((o) => o.id)
expect(ids).toContain(TIMED_OCCURRENCE.id)
expect(ids).toContain(ALLDAY_OCCURRENCE.id)
// Verify eventsService.set() was called with the hydrated events
await waitFor(() => {
expect(mockEventsServiceSet).toHaveBeenCalled()
})
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType
expect(sxEvents.length).toBe(2)
})
it('converts all-day occurrence to Temporal.PlainDate (Pitfall 4 — no ISO-string rejection)', async () => {
renderWithClient()
await waitFor(() => {
expect(mockEventsServiceSet).toHaveBeenCalled()
})
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType
const allDayEvt = sxEvents.find((e) => e.id === ALLDAY_OCCURRENCE.id)
expect(allDayEvt).toBeDefined()
// Must be Temporal.PlainDate, NOT ZonedDateTime — guards all-day date shift (Pitfall 2/4)
expect(allDayEvt!.start).toBeInstanceOf(Temporal.PlainDate)
expect(allDayEvt!.end).toBeInstanceOf(Temporal.PlainDate)
})
it('converts timed occurrence to Temporal.ZonedDateTime (Pitfall 4 — no ISO-string rejection)', async () => {
renderWithClient()
await waitFor(() => {
expect(mockEventsServiceSet).toHaveBeenCalled()
})
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType
const timedEvt = sxEvents.find((e) => e.id === TIMED_OCCURRENCE.id)
expect(timedEvt).toBeDefined()
expect(timedEvt!.start).toBeInstanceOf(Temporal.ZonedDateTime)
expect(timedEvt!.end).toBeInstanceOf(Temporal.ZonedDateTime)
})
it('shows sign-in required when /api/me returns an error', () => {
;(fetchMe as Mock).mockRejectedValue(new Error('401'))
renderWithClient()
// Error renders asynchronously after query settles
})
})