From 2eb280cc07ed5786acb09efbe5e6c7c5728c580f Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 20 Mar 2026 19:51:23 +0100 Subject: [PATCH] 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) --- .../web/e2e/week-view-interactions.spec.ts | 184 ++++++++++++++++++ .../lib/composables/useDragToCreate.test.ts | 99 ++++++++++ .../lib/composables/useEventDragDrop.test.ts | 174 +++++++++++++++++ .../lib/composables/useTaskDragDrop.test.ts | 134 +++++++++++++ 4 files changed, 591 insertions(+) create mode 100644 apps/calendar/apps/web/e2e/week-view-interactions.spec.ts create mode 100644 apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts create mode 100644 apps/calendar/apps/web/src/lib/composables/useEventDragDrop.test.ts create mode 100644 apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.test.ts diff --git a/apps/calendar/apps/web/e2e/week-view-interactions.spec.ts b/apps/calendar/apps/web/e2e/week-view-interactions.spec.ts new file mode 100644 index 000000000..ae111294d --- /dev/null +++ b/apps/calendar/apps/web/e2e/week-view-interactions.spec.ts @@ -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(); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts b/apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts new file mode 100644 index 000000000..6ba089f64 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useDragToCreate.test.ts @@ -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; + + 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}$/); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.test.ts b/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.test.ts new file mode 100644 index 000000000..5cdf8bed2 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useEventDragDrop.test.ts @@ -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(); + }); +}); diff --git a/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.test.ts b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.test.ts new file mode 100644 index 000000000..885f63304 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useTaskDragDrop.test.ts @@ -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(); + }); +});