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);
});
});