import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Calendar } from './calendar'; import { CALENDAR_EVENT_CLICK_EVENT, CALENDAR_EVENT_MOVE_EVENT, CALENDAR_NAVIGATE_EVENT, CALENDAR_SELECT_EVENT, CALENDAR_VIEW_CHANGE_EVENT, type CalendarEventClickEvent, type CalendarEventMoveEvent, type CalendarSelectEvent, } from './calendar.types'; const MARKUP = `
`; // Fixed anchor: June 15, 2026 (local components avoid UTC-parse drift). const ANCHOR = new Date(2026, 5, 15); function setup(): HTMLElement { document.body.innerHTML = MARKUP; return document.body.firstElementChild as HTMLElement; } function grid(root: HTMLElement): HTMLElement { return root.querySelector('[data-c42-calendar-grid]')!; } function dayCells(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('[data-c42-calendar-day]')); } function cell(root: HTMLElement, key: string): HTMLElement | null { return root.querySelector(`[data-c42-calendar-day][data-date="${key}"]`); } function press(el: HTMLElement, key: string): void { el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); } describe('Calendar', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('throws when the grid is missing', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new Calendar(root)).toThrow(/grid/); }); it('renders a 6-week month grid with weekday headers', () => { const root = setup(); new Calendar(root, { defaultDate: ANCHOR }); expect(grid(root).getAttribute('role')).toBe('grid'); expect(dayCells(root)).toHaveLength(42); expect(root.querySelectorAll('[data-c42-calendar-weekday]')).toHaveLength(7); expect(root.dataset.view).toBe('month'); }); it('shows the period title', () => { const root = setup(); new Calendar(root, { defaultDate: ANCHOR, locale: 'en-US' }); expect(root.querySelector('[data-c42-calendar-title]')!.textContent).toContain('2026'); }); it('marks today exactly once when viewing the current month', () => { const root = setup(); new Calendar(root); expect(root.querySelectorAll('[data-c42-calendar-day][data-today]')).toHaveLength(1); }); it('flags days outside the current month', () => { const root = setup(); new Calendar(root, { defaultDate: ANCHOR }); const outside = root.querySelectorAll('[data-c42-calendar-day][data-outside]'); expect(outside.length).toBeGreaterThan(0); expect(cell(root, '2026-06-15')!.hasAttribute('data-outside')).toBe(false); }); it('switches to week and day views and emits viewchange', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); const spy = vi.fn(); cal.on(CALENDAR_VIEW_CHANGE_EVENT, spy); cal.setView('week'); expect(dayCells(root)).toHaveLength(7); cal.setView('day'); expect(dayCells(root)).toHaveLength(1); expect(root.querySelectorAll('[data-c42-calendar-weekday]')).toHaveLength(0); expect(spy).toHaveBeenCalledTimes(2); }); it('navigates to the next/previous month and emits navigate', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); const spy = vi.fn(); cal.on(CALENDAR_NAVIGATE_EVENT, spy); cal.navigate(1); expect(cal.currentDate.getMonth()).toBe(6); // July cal.navigate(-1); expect(cal.currentDate.getMonth()).toBe(5); // back to June expect(spy).toHaveBeenCalledTimes(2); }); it('returns to today via goToToday', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); cal.goToToday(); const today = new Date(); expect(cal.currentDate.getMonth()).toBe(today.getMonth()); expect(cal.currentDate.getFullYear()).toBe(today.getFullYear()); }); it('selects a date and emits select with a dateKey', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); const spy = vi.fn(); cal.on(CALENDAR_SELECT_EVENT, spy); cal.selectDate(new Date(2026, 5, 20)); expect(spy.mock.calls[0][0].detail.dateKey).toBe('2026-06-20'); expect(cell(root, '2026-06-20')!.dataset.selected).toBe('true'); }); it('selects a day when its cell is clicked', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); const spy = vi.fn(); cal.on(CALENDAR_SELECT_EVENT, spy); cell(root, '2026-06-10')!.click(); expect(spy.mock.calls[0][0].detail.dateKey).toBe('2026-06-10'); }); it('moves the focused day with arrow keys and selects on Enter', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); expect(cell(root, '2026-06-15')!.dataset.focused).toBe('true'); press(grid(root), 'ArrowRight'); expect(cell(root, '2026-06-16')!.dataset.focused).toBe('true'); press(grid(root), 'ArrowDown'); expect(cell(root, '2026-06-23')!.dataset.focused).toBe('true'); const spy = vi.fn(); cal.on(CALENDAR_SELECT_EVENT, spy); press(grid(root), 'Enter'); expect(spy.mock.calls[0][0].detail.dateKey).toBe('2026-06-23'); }); it('shifts the period when arrow navigation crosses the visible range', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); cal.setView('day'); press(grid(root), 'ArrowRight'); expect(cal.currentDate.getDate()).toBe(16); }); it('renders events into their day cell', () => { const root = setup(); new Calendar(root, { defaultDate: ANCHOR, events: [{ id: 'e1', title: 'Standup', start: new Date(2026, 5, 20, 9, 0) }], }); const eventEl = cell(root, '2026-06-20')!.querySelector('[data-c42-calendar-event]'); expect(eventEl).not.toBeNull(); expect(eventEl!.textContent).toBe('Standup'); expect((eventEl as HTMLElement).dataset.eventId).toBe('e1'); }); it('emits eventclick when an event is clicked', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR, events: [{ id: 'e1', title: 'Standup', start: new Date(2026, 5, 20, 9, 0) }], }); const spy = vi.fn(); cal.on(CALENDAR_EVENT_CLICK_EVENT, spy); cell(root, '2026-06-20')!.querySelector('[data-c42-calendar-event]')!.click(); expect(spy.mock.calls[0][0].detail.id).toBe('e1'); }); it('adds, lists and removes events', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); cal.addEvent({ id: 'a', title: 'A', start: new Date(2026, 5, 18, 8) }); cal.addEvent({ id: 'b', title: 'B', start: new Date(2026, 5, 19, 8) }); expect(cal.getEvents()).toHaveLength(2); expect(cal.removeEvent('a')).toBe(true); expect(cal.getEvents()).toHaveLength(1); expect(cell(root, '2026-06-18')!.querySelector('[data-c42-calendar-event]')).toBeNull(); }); it('moves an event to a new day keeping its time, emitting eventmove', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR, events: [{ id: 'e1', title: 'Standup', start: new Date(2026, 5, 20, 10, 30) }], }); const spy = vi.fn(); cal.on(CALENDAR_EVENT_MOVE_EVENT, spy); expect(cal.moveEvent('e1', new Date(2026, 5, 22))).toBe(true); const detail = spy.mock.calls[0][0].detail; expect(detail.start.getDate()).toBe(22); expect(detail.start.getHours()).toBe(10); expect(detail.start.getMinutes()).toBe(30); expect(cell(root, '2026-06-22')!.querySelector('[data-c42-calendar-event]')).not.toBeNull(); }); it('moveEvent returns false for unknown id', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); expect(cal.moveEvent('nope', new Date(2026, 5, 22))).toBe(false); }); it('marks an event as dragging on pointerdown and clears on pointerup', () => { const root = setup(); new Calendar(root, { defaultDate: ANCHOR, events: [{ id: 'e1', title: 'Standup', start: new Date(2026, 5, 20, 9, 0) }], }); const eventEl = cell(root, '2026-06-20')!.querySelector( '[data-c42-calendar-event]', )!; eventEl.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); expect(eventEl.dataset.dragging).toBe('true'); document.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); // After pointerup the grid re-renders; the original node is detached and the // drag state is cleared. expect( cell(root, '2026-06-20')! .querySelector('[data-c42-calendar-event]')! .hasAttribute('data-dragging'), ).toBe(false); }); it('removes listeners on destroy', () => { const root = setup(); const cal = new Calendar(root, { defaultDate: ANCHOR }); cal.destroy(); root.querySelector('[data-c42-calendar-next]')!.click(); expect(cal.currentDate.getMonth()).toBe(5); }); });