Files
smoothschedule/frontend/src/lib/__tests__/layoutAlgorithm.test.ts
poduck 8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00

721 lines
20 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { calculateLayout, Event } from '../layoutAlgorithm';
describe('calculateLayout', () => {
describe('basic cases', () => {
it('should return empty array for empty input', () => {
const result = calculateLayout([]);
expect(result).toEqual([]);
});
it('should assign laneIndex 0 to a single event', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
serviceName: 'Service A',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
];
const result = calculateLayout(events);
expect(result).toHaveLength(1);
expect(result[0].laneIndex).toBe(0);
});
it('should preserve all event properties', () => {
const events: Event[] = [
{
id: 1,
resourceId: 2,
title: 'Haircut',
serviceName: 'Basic Haircut',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
status: 'CONFIRMED',
isPaid: true,
},
];
const result = calculateLayout(events);
expect(result[0]).toMatchObject({
id: 1,
resourceId: 2,
title: 'Haircut',
serviceName: 'Basic Haircut',
status: 'CONFIRMED',
isPaid: true,
});
expect(result[0].start).toEqual(events[0].start);
expect(result[0].end).toEqual(events[0].end);
});
});
describe('non-overlapping events', () => {
it('should assign laneIndex 0 to all non-overlapping sequential events', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
];
const result = calculateLayout(events);
expect(result).toHaveLength(3);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(0);
expect(result[2].laneIndex).toBe(0);
});
it('should assign laneIndex 0 to non-overlapping events with gaps', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2025-01-01T14:00:00'),
end: new Date('2025-01-01T15:00:00'),
},
];
const result = calculateLayout(events);
expect(result.every(event => event.laneIndex === 0)).toBe(true);
});
});
describe('edge case: events ending when another starts', () => {
it('should assign same lane when event ends exactly when another starts', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(0); // Can reuse lane since end == start
});
it('should handle millisecond precision for exact boundaries', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00.000Z'),
end: new Date('2025-01-01T11:00:00.000Z'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T11:00:00.000Z'),
end: new Date('2025-01-01T12:00:00.000Z'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(0);
});
it('should assign different lane when event starts one millisecond before another ends', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00.000Z'),
end: new Date('2025-01-01T11:00:00.000Z'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T10:59:59.999Z'),
end: new Date('2025-01-01T12:00:00.000Z'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1); // Overlaps by 1ms
});
});
describe('two overlapping events', () => {
it('should assign different lanes to overlapping events', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T11:30:00'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
});
it('should handle events where one completely contains another', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Long Event',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T13:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Short Event',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
});
it('should handle events starting at the same time', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
});
});
describe('multiple overlapping events', () => {
it('should handle three overlapping events requiring three lanes', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T12:30:00'),
},
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T13:00:00'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
expect(result[2].laneIndex).toBe(2);
});
it('should efficiently reuse lanes when earlier events end', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T09:30:00'),
end: new Date('2025-01-01T11:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
];
const result = calculateLayout(events);
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
expect(result[2].laneIndex).toBe(0); // Can reuse lane 0 since event 1 ended
});
it('should handle complex pattern with five overlapping events', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T15:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T12:00:00'),
},
{
id: 4,
resourceId: 1,
title: 'Event 4',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T13:00:00'),
},
{
id: 5,
resourceId: 1,
title: 'Event 5',
start: new Date('2025-01-01T11:30:00'),
end: new Date('2025-01-01T14:00:00'),
},
];
const result = calculateLayout(events);
// Event 1 (9-15): lane 0
// Event 2 (10-11): lane 1
// Event 3 (10:30-12): lane 2 (overlaps with 1 and 2)
// Event 4 (11-13): lane 1 (can reuse, event 2 ended)
// Event 5 (11:30-14): lane 3 (overlaps with 1, 3, and 4)
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
expect(result[2].laneIndex).toBe(2);
expect(result[3].laneIndex).toBe(1); // Reuses lane 1
expect(result[4].laneIndex).toBe(3);
});
});
describe('event ordering', () => {
it('should produce same result regardless of input order', () => {
const events: Event[] = [
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:30:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T11:00:00'),
},
];
const result = calculateLayout(events);
// Should be sorted by start time internally
expect(result[0].id).toBe(1); // Starts at 10:00
expect(result[1].id).toBe(2); // Starts at 10:30
expect(result[2].id).toBe(3); // Starts at 11:00
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
expect(result[2].laneIndex).toBe(1); // Can reuse lane 1
});
it('should handle reverse chronological order', () => {
const events: Event[] = [
{
id: 3,
resourceId: 1,
title: 'Event 3',
start: new Date('2025-01-01T14:00:00'),
end: new Date('2025-01-01T15:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T12:00:00'),
end: new Date('2025-01-01T13:00:00'),
},
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
];
const result = calculateLayout(events);
expect(result[0].id).toBe(1);
expect(result[1].id).toBe(2);
expect(result[2].id).toBe(3);
expect(result.every(event => event.laneIndex === 0)).toBe(true);
});
it('should not modify the original events array', () => {
const events: Event[] = [
{
id: 2,
resourceId: 1,
title: 'Event 2',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
{
id: 1,
resourceId: 1,
title: 'Event 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
];
const originalOrder = events.map(e => e.id);
calculateLayout(events);
const afterOrder = events.map(e => e.id);
expect(afterOrder).toEqual(originalOrder);
});
});
describe('different statuses and properties', () => {
it('should handle events with all different statuses', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Pending',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
status: 'PENDING',
},
{
id: 2,
resourceId: 1,
title: 'Confirmed',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T11:30:00'),
status: 'CONFIRMED',
},
{
id: 3,
resourceId: 1,
title: 'Completed',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00'),
status: 'COMPLETED',
},
{
id: 4,
resourceId: 1,
title: 'Cancelled',
start: new Date('2025-01-01T11:30:00'),
end: new Date('2025-01-01T12:30:00'),
status: 'CANCELLED',
},
{
id: 5,
resourceId: 1,
title: 'No Show',
start: new Date('2025-01-01T12:00:00'),
end: new Date('2025-01-01T13:00:00'),
status: 'NO_SHOW',
},
];
const result = calculateLayout(events);
expect(result).toHaveLength(5);
// All statuses should be preserved
expect(result[0].status).toBe('PENDING');
expect(result[1].status).toBe('CONFIRMED');
expect(result[2].status).toBe('COMPLETED');
expect(result[3].status).toBe('CANCELLED');
expect(result[4].status).toBe('NO_SHOW');
});
it('should handle events with mixed isPaid values', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Paid',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
isPaid: true,
},
{
id: 2,
resourceId: 1,
title: 'Unpaid',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T11:30:00'),
isPaid: false,
},
{
id: 3,
resourceId: 1,
title: 'No payment info',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00'),
},
];
const result = calculateLayout(events);
expect(result[0].isPaid).toBe(true);
expect(result[1].isPaid).toBe(false);
expect(result[2].isPaid).toBeUndefined();
});
it('should handle events with different resourceIds', () => {
// Note: The algorithm doesn't filter by resourceId, it assigns lanes globally
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Resource 1',
start: new Date('2025-01-01T10:00:00'),
end: new Date('2025-01-01T11:00:00'),
},
{
id: 2,
resourceId: 2,
title: 'Resource 2',
start: new Date('2025-01-01T10:30:00'),
end: new Date('2025-01-01T11:30:00'),
},
];
const result = calculateLayout(events);
// Even though different resources, they still get different lanes
expect(result[0].laneIndex).toBe(0);
expect(result[1].laneIndex).toBe(1);
});
});
describe('real-world scenarios', () => {
it('should handle a busy day with overlapping appointments', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Client A - Haircut',
serviceName: 'Haircut',
start: new Date('2025-01-15T09:00:00'),
end: new Date('2025-01-15T09:30:00'),
status: 'CONFIRMED',
isPaid: true,
},
{
id: 2,
resourceId: 1,
title: 'Client B - Color',
serviceName: 'Hair Color',
start: new Date('2025-01-15T09:15:00'),
end: new Date('2025-01-15T11:00:00'),
status: 'CONFIRMED',
isPaid: false,
},
{
id: 3,
resourceId: 1,
title: 'Client C - Trim',
serviceName: 'Trim',
start: new Date('2025-01-15T09:30:00'),
end: new Date('2025-01-15T10:00:00'),
status: 'PENDING',
isPaid: false,
},
{
id: 4,
resourceId: 1,
title: 'Client D - Consultation',
serviceName: 'Consultation',
start: new Date('2025-01-15T11:00:00'),
end: new Date('2025-01-15T11:30:00'),
status: 'CONFIRMED',
isPaid: true,
},
];
const result = calculateLayout(events);
expect(result).toHaveLength(4);
expect(result[0].laneIndex).toBe(0); // 9:00-9:30
expect(result[1].laneIndex).toBe(1); // 9:15-11:00 overlaps with first
expect(result[2].laneIndex).toBe(0); // 9:30-10:00 can reuse lane 0
expect(result[3].laneIndex).toBe(0); // 11:00-11:30 can reuse lane 0
});
it('should handle lunch break pattern', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Morning Appointment',
start: new Date('2025-01-15T11:00:00'),
end: new Date('2025-01-15T12:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Afternoon Appointment',
start: new Date('2025-01-15T13:00:00'),
end: new Date('2025-01-15T14:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Late Appointment',
start: new Date('2025-01-15T14:00:00'),
end: new Date('2025-01-15T15:00:00'),
},
];
const result = calculateLayout(events);
expect(result.every(event => event.laneIndex === 0)).toBe(true);
});
it('should handle back-to-back events spanning multiple days', () => {
const events: Event[] = [
{
id: 1,
resourceId: 1,
title: 'Day 1 Morning',
start: new Date('2025-01-15T09:00:00'),
end: new Date('2025-01-15T10:00:00'),
},
{
id: 2,
resourceId: 1,
title: 'Day 1 Afternoon',
start: new Date('2025-01-15T14:00:00'),
end: new Date('2025-01-15T15:00:00'),
},
{
id: 3,
resourceId: 1,
title: 'Day 2 Morning',
start: new Date('2025-01-16T09:00:00'),
end: new Date('2025-01-16T10:00:00'),
},
];
const result = calculateLayout(events);
expect(result.every(event => event.laneIndex === 0)).toBe(true);
});
});
});