test(calendar): add composable unit tests and WeekView E2E interaction tests

Unit tests (vitest):
- useEventDragDrop.test.ts: 7 tests (idle state, drag start, resize start,
  cancel, hasMoved reset, getResizePreviewTime, preview positions)
- useTaskDragDrop.test.ts: 5 tests (idle state, drag start, resize start,
  cancel, preview position calculation)
- useDragToCreate.test.ts: 4 tests (idle state, guard when other op active,
  cancel, preview time format)

E2E tests (playwright):
- week-view-interactions.spec.ts: 7 tests covering drag-to-create (click,
  drag with time range, escape cancel), event card positioning, current time
  indicator, day headers, and today highlighting

All 116 unit tests passing across 12 test files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 19:51:23 +01:00
parent 5832326010
commit 2eb280cc07
4 changed files with 591 additions and 0 deletions

View file

@ -0,0 +1,184 @@
import { test, expect, dismissOnboarding } from './fixtures/auth';
const BACKEND_URL = process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
test.describe('WeekView Interactions', () => {
test.beforeAll(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/health`, {
signal: AbortSignal.timeout(3000),
});
if (!res.ok) test.skip(true, 'Calendar backend is not running');
} catch {
test.skip(true, 'Calendar backend is not reachable');
}
});
test.beforeEach(async ({ page }) => {
await page.goto('/');
await dismissOnboarding(page);
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
});
test('drag-to-create: clicking on empty time slot opens quick create overlay', async ({
page,
}) => {
// Find a day column in the week view
const dayColumn = page.locator('.day-column').first();
await expect(dayColumn).toBeVisible();
const box = await dayColumn.boundingBox();
if (!box) return;
// Click in the middle of the day column (should open quick create)
await dayColumn.click({ position: { x: box.width / 2, y: box.height * 0.4 } });
// Quick event overlay should appear
const overlay = page.locator('.quick-event-overlay');
await expect(overlay).toBeVisible({ timeout: 5000 });
// Close it
await page.keyboard.press('Escape');
});
test('drag-to-create: drag creates event with correct time range', async ({ page }) => {
const dayColumn = page.locator('.day-column').first();
await expect(dayColumn).toBeVisible();
const box = await dayColumn.boundingBox();
if (!box) return;
// Drag from ~10am to ~12pm area
const startY = box.y + box.height * 0.35;
const endY = box.y + box.height * 0.5;
const centerX = box.x + box.width / 2;
await page.mouse.move(centerX, startY);
await page.mouse.down();
await page.mouse.move(centerX, endY, { steps: 5 });
await page.mouse.up();
// Quick event overlay should appear
const overlay = page.locator('.quick-event-overlay');
await expect(overlay).toBeVisible({ timeout: 5000 });
// Type a title and save
const uniqueTitle = `Drag Create ${Date.now()}`;
await page.keyboard.type(uniqueTitle);
await overlay.getByRole('button', { name: /speichern/i }).click();
await expect(overlay).not.toBeVisible({ timeout: 5000 });
// Event should appear in the grid
const eventCard = page.locator('.event-card').filter({ hasText: uniqueTitle });
await expect(eventCard).toBeVisible({ timeout: 5000 });
// Cleanup: delete the event
await eventCard.click();
const editOverlay = page.locator('.quick-event-overlay');
await expect(editOverlay).toBeVisible({ timeout: 5000 });
const deleteBtn = editOverlay.getByRole('button', { name: /löschen/i });
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
}
}
});
test('escape cancels drag-to-create', async ({ page }) => {
const dayColumn = page.locator('.day-column').first();
await expect(dayColumn).toBeVisible();
const box = await dayColumn.boundingBox();
if (!box) return;
const startY = box.y + box.height * 0.3;
const centerX = box.x + box.width / 2;
// Start dragging
await page.mouse.move(centerX, startY);
await page.mouse.down();
await page.mouse.move(centerX, startY + 50, { steps: 3 });
// Press escape to cancel
await page.keyboard.press('Escape');
await page.mouse.up();
// No overlay should appear
const overlay = page.locator('.quick-event-overlay');
await expect(overlay).not.toBeVisible({ timeout: 1000 });
});
test('event card shows in correct position within time grid', async ({ page }) => {
const uniqueTitle = `Position Test ${Date.now()}`;
// Create an event by clicking on the grid
const dayColumn = page.locator('.day-column').first();
await expect(dayColumn).toBeVisible();
const box = await dayColumn.boundingBox();
if (!box) return;
await dayColumn.click({ position: { x: box.width / 2, y: box.height * 0.5 } });
const overlay = page.locator('.quick-event-overlay');
await expect(overlay).toBeVisible({ timeout: 5000 });
await page.keyboard.type(uniqueTitle);
await overlay.getByRole('button', { name: /speichern/i }).click();
await expect(overlay).not.toBeVisible({ timeout: 5000 });
// Verify the event card exists and has a top style (positioned in grid)
const eventCard = page.locator('.event-card').filter({ hasText: uniqueTitle });
await expect(eventCard).toBeVisible({ timeout: 5000 });
const style = await eventCard.getAttribute('style');
expect(style).toContain('top:');
expect(style).toContain('height:');
// Cleanup
await eventCard.click();
const editOverlay = page.locator('.quick-event-overlay');
const deleteBtn = editOverlay.getByRole('button', { name: /löschen/i });
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
}
}
});
test('week view shows current time indicator on today', async ({ page }) => {
const timeIndicator = page.locator('.time-indicator');
// There should be at least one time indicator (on today's column)
await expect(timeIndicator.first()).toBeVisible({ timeout: 5000 });
// It should have a top percentage style
const style = await timeIndicator.first().getAttribute('style');
expect(style).toContain('top:');
});
test('week view shows correct day headers', async ({ page }) => {
const dayHeaders = page.locator('.day-header');
const count = await dayHeaders.count();
// Should have 5 (weekdays only) or 7 (full week) day headers
expect(count === 5 || count === 7).toBe(true);
// Each header should have a day name and number
for (let i = 0; i < count; i++) {
const dayName = dayHeaders.nth(i).locator('.day-name');
const dayNumber = dayHeaders.nth(i).locator('.day-number');
await expect(dayName).toBeVisible();
await expect(dayNumber).toBeVisible();
}
});
test('today column is highlighted', async ({ page }) => {
const todayColumn = page.locator('.day-column.today');
await expect(todayColumn).toBeVisible({ timeout: 5000 });
const todayHeader = page.locator('.day-header.today');
await expect(todayHeader).toBeVisible();
});
});

View file

@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Polyfill PointerEvent for jsdom
if (typeof globalThis.PointerEvent === 'undefined') {
globalThis.PointerEvent = class PointerEvent extends MouseEvent {
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
}
} as unknown as typeof PointerEvent;
}
vi.mock('$lib/utils/calendarConstants', () => ({
SNAP_INTERVAL_MINUTES: 15,
}));
import { useDragToCreate } from './useDragToCreate.svelte';
function createMockContainer() {
return {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 700,
bottom: 960,
width: 700,
height: 960,
}),
parentElement: { scrollTop: 0 },
} as unknown as HTMLElement;
}
function makeDays(): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date('2026-03-02');
d.setDate(d.getDate() + i);
return d;
});
}
function minutesToPercent(minutes: number): number {
const firstHour = 0;
const totalHours = 24;
const adjusted = minutes - firstHour * 60;
return (adjusted / (totalHours * 60)) * 100;
}
describe('useDragToCreate', () => {
let onCreateEnd: ReturnType<typeof vi.fn>;
function createInstance(overrides = {}) {
const container = createMockContainer();
onCreateEnd = vi.fn();
return useDragToCreate(() => ({
containerEl: container,
days: makeDays(),
firstVisibleHour: 0,
lastVisibleHour: 24,
totalVisibleHours: 24,
hourHeight: 40,
minutesToPercent,
isOtherOperationActive: () => false,
onCreateEnd,
...overrides,
}));
}
it('should start in idle state', () => {
const dtc = createInstance();
expect(dtc.isCreating).toBe(false);
expect(dtc.createTargetDay).toBeNull();
});
it('should not start create if other operation is active', () => {
const dtc = createInstance({ isOtherOperationActive: () => true });
const target = document.createElement('div');
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
Object.defineProperty(event, 'target', { value: target });
dtc.startCreate(event);
expect(dtc.isCreating).toBe(false);
});
it('should cancel on cancel()', () => {
const dtc = createInstance();
const target = document.createElement('div');
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
Object.defineProperty(event, 'target', { value: target });
dtc.startCreate(event);
dtc.cancel();
expect(dtc.isCreating).toBe(false);
expect(dtc.createTargetDay).toBeNull();
});
it('should generate correct preview time format', () => {
const dtc = createInstance();
// getCreatePreviewTime uses internal state, returns HH:MM - HH:MM format
const time = dtc.getCreatePreviewTime();
expect(time).toMatch(/^\d{2}:\d{2} - \d{2}:\d{2}$/);
});
});

View file

@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Polyfill PointerEvent for jsdom
if (typeof globalThis.PointerEvent === 'undefined') {
globalThis.PointerEvent = class PointerEvent extends MouseEvent {
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
}
} as unknown as typeof PointerEvent;
}
vi.mock('$lib/utils/eventDateHelpers', () => ({
toDate: (d: string | Date) => new Date(d),
}));
vi.mock('$lib/stores/events.svelte', () => ({
eventsStore: {
isDraftEvent: vi.fn(() => false),
updateDraftEvent: vi.fn(),
updateEvent: vi.fn().mockResolvedValue({ data: {}, error: null }),
},
}));
vi.mock('$lib/utils/calendarConstants', () => ({
SNAP_INTERVAL_MINUTES: 15,
}));
import { useEventDragDrop } from './useEventDragDrop.svelte';
function createMockContainer() {
return {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 700,
bottom: 960,
width: 700,
height: 960,
}),
parentElement: { scrollTop: 0 },
} as unknown as HTMLElement;
}
function makeDays(): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date('2026-03-02');
d.setDate(d.getDate() + i);
return d;
});
}
function minutesToPercent(minutes: number): number {
return (minutes / (24 * 60)) * 100;
}
function makeConfig() {
return {
containerEl: createMockContainer(),
days: makeDays(),
firstVisibleHour: 0,
lastVisibleHour: 24,
totalVisibleHours: 24,
hourHeight: 40,
minutesToPercent,
};
}
describe('useEventDragDrop', () => {
it('should start in idle state', () => {
const dd = useEventDragDrop(() => makeConfig());
expect(dd.isDragging).toBe(false);
expect(dd.isResizing).toBe(false);
expect(dd.draggedEvent).toBeNull();
expect(dd.resizeEvent).toBeNull();
expect(dd.hasMoved).toBe(false);
});
it('should set isDragging when startDrag is called', () => {
const dd = useEventDragDrop(() => makeConfig());
const event = {
id: 'evt-1',
startTime: '2026-03-02T10:00:00',
endTime: '2026-03-02T11:00:00',
};
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 200,
});
dd.startDrag(event as any, pointerEvent);
expect(dd.isDragging).toBe(true);
expect(dd.draggedEvent).toBeTruthy();
expect(dd.draggedEvent!.id).toBe('evt-1');
// Cleanup
dd.cancel();
});
it('should set isResizing when startResize is called', () => {
const dd = useEventDragDrop(() => makeConfig());
const event = {
id: 'evt-1',
startTime: '2026-03-02T10:00:00',
endTime: '2026-03-02T11:00:00',
};
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 200,
});
dd.startResize(event as any, 'bottom', pointerEvent);
expect(dd.isResizing).toBe(true);
expect(dd.resizeEvent).toBeTruthy();
dd.cancel();
});
it('should reset all state on cancel', () => {
const dd = useEventDragDrop(() => makeConfig());
const event = {
id: 'evt-1',
startTime: '2026-03-02T10:00:00',
endTime: '2026-03-02T11:00:00',
};
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 200,
});
dd.startDrag(event as any, pointerEvent);
expect(dd.isDragging).toBe(true);
dd.cancel();
expect(dd.isDragging).toBe(false);
expect(dd.draggedEvent).toBeNull();
});
it('should reset hasMoved on resetHasMoved', () => {
const dd = useEventDragDrop(() => makeConfig());
// hasMoved starts false
expect(dd.hasMoved).toBe(false);
dd.resetHasMoved();
expect(dd.hasMoved).toBe(false);
});
it('should return empty string for getResizePreviewTime when not resizing', () => {
const dd = useEventDragDrop(() => makeConfig());
expect(dd.getResizePreviewTime()).toBe('');
});
it('should calculate preview positions on drag start', () => {
const dd = useEventDragDrop(() => makeConfig());
const event = {
id: 'evt-1',
startTime: '2026-03-02T12:00:00', // noon
endTime: '2026-03-02T13:00:00', // 1pm
};
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 100,
clientY: 480, // middle of container
});
dd.startDrag(event as any, pointerEvent);
// Preview top should be around 50% (12:00 = 720 min / 1440 min)
expect(dd.dragPreviewTop).toBeCloseTo(50, 0);
// Height should be ~4.17% (60 min / 1440 min)
expect(dd.dragPreviewHeight).toBeCloseTo(4.17, 0);
dd.cancel();
});
});

View file

@ -0,0 +1,134 @@
import { describe, it, expect, vi } from 'vitest';
// Polyfill PointerEvent for jsdom
if (typeof globalThis.PointerEvent === 'undefined') {
globalThis.PointerEvent = class PointerEvent extends MouseEvent {
constructor(type: string, params: PointerEventInit = {}) {
super(type, params);
}
} as unknown as typeof PointerEvent;
}
vi.mock('$lib/stores/todos.svelte', () => ({
todosStore: {
updateTodo: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('$lib/utils/calendarConstants', () => ({
SNAP_INTERVAL_MINUTES: 15,
}));
import { useTaskDragDrop } from './useTaskDragDrop.svelte';
function createMockContainer() {
const el = {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 700,
bottom: 960,
width: 700,
height: 960,
}),
querySelectorAll: () => [],
querySelector: () => null,
parentElement: { scrollTop: 0 },
} as unknown as HTMLElement;
return el;
}
function makeDays(): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date('2026-03-02');
d.setDate(d.getDate() + i);
return d;
});
}
describe('useTaskDragDrop', () => {
function createInstance() {
return useTaskDragDrop(() => ({
containerEl: createMockContainer(),
days: makeDays(),
firstVisibleHour: 0,
totalVisibleHours: 24,
}));
}
it('should start in idle state', () => {
const td = createInstance();
expect(td.isTaskDragging).toBe(false);
expect(td.isTaskResizing).toBe(false);
expect(td.draggedTask).toBeNull();
expect(td.resizeTask).toBeNull();
expect(td.hasMoved).toBe(false);
});
it('should set isTaskDragging when startDrag is called', () => {
const td = createInstance();
const task = {
id: 'task-1',
scheduledStartTime: '10:00',
estimatedDuration: 30,
};
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
td.startDrag(task as any, event);
expect(td.isTaskDragging).toBe(true);
expect(td.draggedTask).toBeTruthy();
expect(td.draggedTask!.id).toBe('task-1');
td.cancel();
});
it('should set isTaskResizing when startResize is called', () => {
const td = createInstance();
const task = {
id: 'task-1',
scheduledStartTime: '10:00',
estimatedDuration: 30,
};
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
td.startResize(task as any, 'bottom', event);
expect(td.isTaskResizing).toBe(true);
expect(td.resizeTask).toBeTruthy();
td.cancel();
});
it('should reset state on cancel', () => {
const td = createInstance();
const task = { id: 'task-1', scheduledStartTime: '10:00', estimatedDuration: 30 };
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 200 });
td.startDrag(task as any, event);
expect(td.isTaskDragging).toBe(true);
td.cancel();
expect(td.isTaskDragging).toBe(false);
expect(td.draggedTask).toBeNull();
});
it('should calculate preview position from task time', () => {
const td = createInstance();
const task = {
id: 'task-1',
scheduledStartTime: '12:00', // noon
estimatedDuration: 60,
};
const event = new PointerEvent('pointerdown', { clientX: 100, clientY: 480 });
td.startDrag(task as any, event);
// Preview top should be around 50% (12:00 / 24:00)
expect(td.taskDragPreviewTop).toBeCloseTo(50, 0);
// Height should be ~4.17% (60 min / 1440 min)
expect(td.taskDragPreviewHeight).toBeCloseTo(4.17, 0);
td.cancel();
});
});