Improves date handling and event stacking
Enhances date validation and timezone handling using DateService, ensuring data integrity and consistency. Refactors event rendering and dragging to correctly handle date transformations. Adds a test plan for event stacking and z-index management. Fixes edge cases in navigation and date calculations for week/year boundaries and DST transitions.
This commit is contained in:
parent
a86a736340
commit
9bc082eed4
20 changed files with 1641 additions and 41 deletions
295
test/managers/NavigationManager.edge-cases.test.ts
Normal file
295
test/managers/NavigationManager.edge-cases.test.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { NavigationManager } from '../../src/managers/NavigationManager';
|
||||
import { EventBus } from '../../src/core/EventBus';
|
||||
import { EventRenderingService } from '../../src/renderers/EventRendererManager';
|
||||
import { DateService } from '../../src/utils/DateService';
|
||||
|
||||
describe('NavigationManager - Edge Cases', () => {
|
||||
let navigationManager: NavigationManager;
|
||||
let eventBus: EventBus;
|
||||
let dateService: DateService;
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus = new EventBus();
|
||||
const mockEventRenderer = {} as EventRenderingService;
|
||||
navigationManager = new NavigationManager(eventBus, mockEventRenderer);
|
||||
dateService = new DateService('Europe/Copenhagen');
|
||||
});
|
||||
|
||||
describe('Week 53 Navigation', () => {
|
||||
it('should correctly navigate to week 53 (year 2020)', () => {
|
||||
// Dec 28, 2020 is start of week 53
|
||||
const week53Start = new Date(2020, 11, 28);
|
||||
|
||||
const weekNum = dateService.getWeekNumber(week53Start);
|
||||
expect(weekNum).toBe(53);
|
||||
|
||||
const weekBounds = dateService.getWeekBounds(week53Start);
|
||||
expect(weekBounds.start.getDate()).toBe(28);
|
||||
expect(weekBounds.start.getMonth()).toBe(11); // December
|
||||
expect(weekBounds.start.getFullYear()).toBe(2020);
|
||||
});
|
||||
|
||||
it('should navigate from week 53 to week 1 of next year', () => {
|
||||
const week53 = new Date(2020, 11, 28); // Week 53, 2020
|
||||
|
||||
// Add 1 week should go to week 1 of 2021
|
||||
const nextWeek = dateService.addWeeks(week53, 1);
|
||||
const nextWeekNum = dateService.getWeekNumber(nextWeek);
|
||||
|
||||
expect(nextWeek.getFullYear()).toBe(2021);
|
||||
expect(nextWeekNum).toBe(1);
|
||||
});
|
||||
|
||||
it('should navigate from week 1 back to week 53 of previous year', () => {
|
||||
const week1_2021 = new Date(2021, 0, 4); // Monday Jan 4, 2021 (week 1)
|
||||
|
||||
// Subtract 1 week should go to week 53 of 2020
|
||||
const prevWeek = dateService.addWeeks(week1_2021, -1);
|
||||
const prevWeekNum = dateService.getWeekNumber(prevWeek);
|
||||
|
||||
expect(prevWeek.getFullYear()).toBe(2020);
|
||||
expect(prevWeekNum).toBe(53);
|
||||
});
|
||||
|
||||
it('should handle years without week 53 (2021)', () => {
|
||||
const dec27_2021 = new Date(2021, 11, 27); // Monday Dec 27, 2021
|
||||
const weekNum = dateService.getWeekNumber(dec27_2021);
|
||||
|
||||
expect(weekNum).toBe(52); // No week 53 in 2021
|
||||
|
||||
const nextWeek = dateService.addWeeks(dec27_2021, 1);
|
||||
const nextWeekNum = dateService.getWeekNumber(nextWeek);
|
||||
|
||||
// ISO week logic: Adding 1 week from Dec 27 gives Jan 3, which is week 1 of 2022
|
||||
expect(nextWeekNum).toBe(1); // Week 1 of 2022
|
||||
|
||||
// Jan 3, 2022 is indeed week 1
|
||||
const jan3_2022 = new Date(2022, 0, 3);
|
||||
expect(dateService.getWeekNumber(jan3_2022)).toBe(1);
|
||||
});
|
||||
|
||||
it('should correctly identify week 53 in 2026', () => {
|
||||
const dec28_2026 = new Date(2026, 11, 28); // Monday Dec 28, 2026
|
||||
const weekNum = dateService.getWeekNumber(dec28_2026);
|
||||
|
||||
expect(weekNum).toBe(53);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Year Boundary Navigation', () => {
|
||||
it('should navigate across year boundary (Dec -> Jan)', () => {
|
||||
const lastWeekDec = new Date(2024, 11, 23); // Dec 23, 2024
|
||||
const firstWeekJan = dateService.addWeeks(lastWeekDec, 1);
|
||||
|
||||
// Adding 1 week gives Dec 30, which is in week 1 of 2025 (ISO week logic)
|
||||
expect(firstWeekJan.getMonth()).toBe(11); // Still December
|
||||
|
||||
const weekNum = dateService.getWeekNumber(firstWeekJan);
|
||||
// Week number can be 1 (of next year) or 52 depending on ISO week rules
|
||||
expect(weekNum).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should navigate across year boundary (Jan -> Dec)', () => {
|
||||
const firstWeekJan = new Date(2024, 0, 1);
|
||||
const lastWeekDec = dateService.addWeeks(firstWeekJan, -1);
|
||||
|
||||
expect(lastWeekDec.getFullYear()).toBe(2023);
|
||||
|
||||
const weekNum = dateService.getWeekNumber(lastWeekDec);
|
||||
expect(weekNum).toBeGreaterThanOrEqual(52);
|
||||
});
|
||||
|
||||
it('should get correct week bounds at year start', () => {
|
||||
const jan1_2024 = new Date(2024, 0, 1); // Monday Jan 1, 2024
|
||||
const weekBounds = dateService.getWeekBounds(jan1_2024);
|
||||
|
||||
// Week should start on Monday
|
||||
const startDayOfWeek = weekBounds.start.getDay();
|
||||
expect(startDayOfWeek).toBe(1); // Monday = 1
|
||||
|
||||
expect(weekBounds.start.getDate()).toBe(1);
|
||||
expect(weekBounds.start.getMonth()).toBe(0); // January
|
||||
});
|
||||
|
||||
it('should get correct week bounds at year end', () => {
|
||||
const dec31_2024 = new Date(2024, 11, 31); // Tuesday Dec 31, 2024
|
||||
const weekBounds = dateService.getWeekBounds(dec31_2024);
|
||||
|
||||
// Week should start on Monday (Dec 30, 2024)
|
||||
expect(weekBounds.start.getDate()).toBe(30);
|
||||
expect(weekBounds.start.getMonth()).toBe(11);
|
||||
expect(weekBounds.start.getFullYear()).toBe(2024);
|
||||
|
||||
// Week should end on Sunday (Jan 5, 2025)
|
||||
expect(weekBounds.end.getDate()).toBe(5);
|
||||
expect(weekBounds.end.getMonth()).toBe(0); // January
|
||||
expect(weekBounds.end.getFullYear()).toBe(2025);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DST Transition Navigation', () => {
|
||||
it('should navigate across spring DST transition (March 2024)', () => {
|
||||
// Spring DST: March 31, 2024, 02:00 -> 03:00
|
||||
const beforeDST = new Date(2024, 2, 25); // Week before DST
|
||||
const duringDST = dateService.addWeeks(beforeDST, 1);
|
||||
|
||||
expect(duringDST.getMonth()).toBe(3); // April
|
||||
expect(dateService.isValid(duringDST)).toBe(true);
|
||||
|
||||
const weekBounds = dateService.getWeekBounds(duringDST);
|
||||
expect(weekBounds.start.getMonth()).toBeGreaterThanOrEqual(2); // March or April
|
||||
});
|
||||
|
||||
it('should navigate across fall DST transition (October 2024)', () => {
|
||||
// Fall DST: October 27, 2024, 03:00 -> 02:00
|
||||
const beforeDST = new Date(2024, 9, 21); // Week before DST
|
||||
const duringDST = dateService.addWeeks(beforeDST, 1);
|
||||
|
||||
expect(duringDST.getMonth()).toBe(9); // October
|
||||
expect(dateService.isValid(duringDST)).toBe(true);
|
||||
|
||||
const weekBounds = dateService.getWeekBounds(duringDST);
|
||||
expect(weekBounds.end.getMonth()).toBeLessThanOrEqual(10); // October or November
|
||||
});
|
||||
|
||||
it('should maintain week integrity across DST', () => {
|
||||
const beforeDST = new Date(2024, 2, 25, 12, 0);
|
||||
const afterDST = dateService.addWeeks(beforeDST, 1);
|
||||
|
||||
// Week bounds should still give 7-day span
|
||||
const weekBounds = dateService.getWeekBounds(afterDST);
|
||||
const daysDiff = (weekBounds.end.getTime() - weekBounds.start.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Should be close to 7 days (accounting for DST hour change)
|
||||
expect(daysDiff).toBeGreaterThanOrEqual(6.9);
|
||||
expect(daysDiff).toBeLessThanOrEqual(7.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Month Boundary Week Navigation', () => {
|
||||
it('should handle week spanning month boundary', () => {
|
||||
const endOfMonth = new Date(2024, 0, 29); // Jan 29, 2024 (Monday)
|
||||
const weekBounds = dateService.getWeekBounds(endOfMonth);
|
||||
|
||||
// Week should span into February
|
||||
expect(weekBounds.end.getMonth()).toBe(1); // February
|
||||
expect(weekBounds.end.getDate()).toBe(4);
|
||||
});
|
||||
|
||||
it('should navigate to next week across month boundary', () => {
|
||||
const lastWeekJan = new Date(2024, 0, 29);
|
||||
const firstWeekFeb = dateService.addWeeks(lastWeekJan, 1);
|
||||
|
||||
expect(firstWeekFeb.getMonth()).toBe(1); // February
|
||||
expect(firstWeekFeb.getDate()).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle February-March boundary in leap year', () => {
|
||||
const lastWeekFeb = new Date(2024, 1, 26); // Feb 26, 2024 (leap year)
|
||||
const weekBounds = dateService.getWeekBounds(lastWeekFeb);
|
||||
|
||||
// Week should span from Feb into March
|
||||
expect(weekBounds.start.getMonth()).toBe(1); // February
|
||||
expect(weekBounds.end.getMonth()).toBe(2); // March
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid Date Navigation', () => {
|
||||
it('should reject navigation to invalid date', () => {
|
||||
const invalidDate = new Date('invalid');
|
||||
const validation = dateService.validateDate(invalidDate);
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject navigation to out-of-bounds date', () => {
|
||||
const outOfBounds = new Date(2150, 0, 1);
|
||||
const validation = dateService.validateDate(outOfBounds);
|
||||
|
||||
expect(validation.valid).toBe(false);
|
||||
expect(validation.error).toContain('bounds');
|
||||
});
|
||||
|
||||
it('should accept valid date within bounds', () => {
|
||||
const validDate = new Date(2024, 6, 15);
|
||||
const validation = dateService.validateDate(validDate);
|
||||
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(validation.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Week Number Edge Cases', () => {
|
||||
it('should handle first day of year in previous year\'s week', () => {
|
||||
// Jan 1, 2023 is a Sunday, part of week 52 of 2022
|
||||
const jan1_2023 = new Date(2023, 0, 1);
|
||||
const weekNum = dateService.getWeekNumber(jan1_2023);
|
||||
|
||||
expect(weekNum).toBe(52); // Part of 2022's last week
|
||||
});
|
||||
|
||||
it('should handle last day of year in next year\'s week', () => {
|
||||
// Dec 31, 2023 is a Sunday, part of week 52 of 2023
|
||||
const dec31_2023 = new Date(2023, 11, 31);
|
||||
const weekNum = dateService.getWeekNumber(dec31_2023);
|
||||
|
||||
expect(weekNum).toBe(52);
|
||||
});
|
||||
|
||||
it('should correctly number weeks in leap year', () => {
|
||||
const dates2024 = [
|
||||
new Date(2024, 0, 1), // Week 1
|
||||
new Date(2024, 6, 1), // Mid-year
|
||||
new Date(2024, 11, 31) // Last week
|
||||
];
|
||||
|
||||
dates2024.forEach(date => {
|
||||
const weekNum = dateService.getWeekNumber(date);
|
||||
expect(weekNum).toBeGreaterThanOrEqual(1);
|
||||
expect(weekNum).toBeLessThanOrEqual(53);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Continuity', () => {
|
||||
it('should maintain continuity over multiple forward navigations', () => {
|
||||
let currentWeek = new Date(2024, 0, 1);
|
||||
|
||||
for (let i = 0; i < 60; i++) { // Navigate 60 weeks forward
|
||||
currentWeek = dateService.addWeeks(currentWeek, 1);
|
||||
expect(dateService.isValid(currentWeek)).toBe(true);
|
||||
}
|
||||
|
||||
// Should be in 2025
|
||||
expect(currentWeek.getFullYear()).toBe(2025);
|
||||
});
|
||||
|
||||
it('should maintain continuity over multiple backward navigations', () => {
|
||||
let currentWeek = new Date(2024, 11, 31);
|
||||
|
||||
for (let i = 0; i < 60; i++) { // Navigate 60 weeks backward
|
||||
currentWeek = dateService.addWeeks(currentWeek, -1);
|
||||
expect(dateService.isValid(currentWeek)).toBe(true);
|
||||
}
|
||||
|
||||
// Should be in 2023
|
||||
expect(currentWeek.getFullYear()).toBe(2023);
|
||||
});
|
||||
|
||||
it('should return to same week after forward+backward navigation', () => {
|
||||
const originalWeek = new Date(2024, 6, 15);
|
||||
const weekBoundsOriginal = dateService.getWeekBounds(originalWeek);
|
||||
|
||||
// Navigate 10 weeks forward, then 10 weeks back
|
||||
const forward = dateService.addWeeks(originalWeek, 10);
|
||||
const backAgain = dateService.addWeeks(forward, -10);
|
||||
|
||||
const weekBoundsBack = dateService.getWeekBounds(backAgain);
|
||||
|
||||
expect(weekBoundsBack.start.getTime()).toBe(weekBoundsOriginal.start.getTime());
|
||||
expect(weekBoundsBack.end.getTime()).toBe(weekBoundsOriginal.end.getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
218
test/utils/DateService.edge-cases.test.ts
Normal file
218
test/utils/DateService.edge-cases.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DateService } from '../../src/utils/DateService';
|
||||
|
||||
describe('DateService - Edge Cases', () => {
|
||||
const dateService = new DateService('Europe/Copenhagen');
|
||||
|
||||
describe('Leap Year Handling', () => {
|
||||
it('should handle February 29 in leap year (2024)', () => {
|
||||
const leapDate = new Date(2024, 1, 29); // Feb 29, 2024
|
||||
expect(dateService.isValid(leapDate)).toBe(true);
|
||||
expect(leapDate.getMonth()).toBe(1); // February
|
||||
expect(leapDate.getDate()).toBe(29);
|
||||
});
|
||||
|
||||
it('should reject February 29 in non-leap year (2023)', () => {
|
||||
const invalidDate = new Date(2023, 1, 29); // Tries Feb 29, 2023
|
||||
// JavaScript auto-corrects to March 1
|
||||
expect(invalidDate.getMonth()).toBe(2); // March
|
||||
expect(invalidDate.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle February 28 in non-leap year', () => {
|
||||
const validDate = new Date(2023, 1, 28); // Feb 28, 2023
|
||||
expect(dateService.isValid(validDate)).toBe(true);
|
||||
expect(validDate.getMonth()).toBe(1); // February
|
||||
expect(validDate.getDate()).toBe(28);
|
||||
});
|
||||
|
||||
it('should correctly add 1 year to Feb 29 (leap year)', () => {
|
||||
const leapDate = new Date(2024, 1, 29);
|
||||
const nextYear = dateService.addDays(leapDate, 365); // 2025 is not leap year
|
||||
|
||||
// Should be Feb 28, 2025 (or March 1 depending on implementation)
|
||||
expect(nextYear.getFullYear()).toBe(2025);
|
||||
expect(nextYear.getMonth()).toBeGreaterThanOrEqual(1); // Feb or March
|
||||
});
|
||||
|
||||
it('should validate leap year dates with isWithinBounds', () => {
|
||||
const leapDate2024 = new Date(2024, 1, 29);
|
||||
const leapDate2000 = new Date(2000, 1, 29);
|
||||
|
||||
expect(dateService.isWithinBounds(leapDate2024)).toBe(true);
|
||||
expect(dateService.isWithinBounds(leapDate2000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ISO Week 53 Handling', () => {
|
||||
it('should correctly identify week 53 in 2020 (has week 53)', () => {
|
||||
const dec31_2020 = new Date(2020, 11, 31); // Dec 31, 2020
|
||||
const weekNum = dateService.getWeekNumber(dec31_2020);
|
||||
expect(weekNum).toBe(53);
|
||||
});
|
||||
|
||||
it('should correctly identify week 53 in 2026 (has week 53)', () => {
|
||||
const dec31_2026 = new Date(2026, 11, 31); // Dec 31, 2026
|
||||
const weekNum = dateService.getWeekNumber(dec31_2026);
|
||||
expect(weekNum).toBe(53);
|
||||
});
|
||||
|
||||
it('should NOT have week 53 in 2021 (goes to week 52)', () => {
|
||||
const dec31_2021 = new Date(2021, 11, 31); // Dec 31, 2021
|
||||
const weekNum = dateService.getWeekNumber(dec31_2021);
|
||||
expect(weekNum).toBe(52);
|
||||
});
|
||||
|
||||
it('should handle transition from week 53 to week 1', () => {
|
||||
const lastDayOf2020 = new Date(2020, 11, 31); // Week 53
|
||||
const firstDayOf2021 = dateService.addDays(lastDayOf2020, 1);
|
||||
|
||||
expect(dateService.getWeekNumber(lastDayOf2020)).toBe(53);
|
||||
expect(dateService.getWeekNumber(firstDayOf2021)).toBe(53); // Still week 53!
|
||||
|
||||
// Monday after should be week 1
|
||||
const firstMonday2021 = new Date(2021, 0, 4);
|
||||
expect(dateService.getWeekNumber(firstMonday2021)).toBe(1);
|
||||
});
|
||||
|
||||
it('should get correct week bounds for week 53', () => {
|
||||
const dec31_2020 = new Date(2020, 11, 31);
|
||||
const weekBounds = dateService.getWeekBounds(dec31_2020);
|
||||
|
||||
// Week 53 of 2020 starts on Monday Dec 28, 2020
|
||||
expect(weekBounds.start.getDate()).toBe(28);
|
||||
expect(weekBounds.start.getMonth()).toBe(11); // December
|
||||
|
||||
// Ends on Sunday Jan 3, 2021
|
||||
expect(weekBounds.end.getDate()).toBe(3);
|
||||
expect(weekBounds.end.getMonth()).toBe(0); // January
|
||||
expect(weekBounds.end.getFullYear()).toBe(2021);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Month Boundary Edge Cases', () => {
|
||||
it('should correctly add months across year boundary', () => {
|
||||
const nov2024 = new Date(2024, 10, 15); // Nov 15, 2024
|
||||
const feb2025 = dateService.addMonths(nov2024, 3);
|
||||
|
||||
expect(feb2025.getFullYear()).toBe(2025);
|
||||
expect(feb2025.getMonth()).toBe(1); // February
|
||||
expect(feb2025.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should handle month-end overflow (Jan 31 + 1 month)', () => {
|
||||
const jan31 = new Date(2024, 0, 31);
|
||||
const result = dateService.addMonths(jan31, 1);
|
||||
|
||||
// date-fns addMonths handles this gracefully
|
||||
expect(result.getMonth()).toBe(1); // February
|
||||
expect(result.getFullYear()).toBe(2024);
|
||||
// Will be Feb 29 (leap year) or last day of Feb
|
||||
});
|
||||
|
||||
it('should handle adding negative months', () => {
|
||||
const mar2024 = new Date(2024, 2, 15); // March 15, 2024
|
||||
const dec2023 = dateService.addMonths(mar2024, -3);
|
||||
|
||||
expect(dec2023.getFullYear()).toBe(2023);
|
||||
expect(dec2023.getMonth()).toBe(11); // December
|
||||
expect(dec2023.getDate()).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Year Boundary Edge Cases', () => {
|
||||
it('should handle year transition (Dec 31 -> Jan 1)', () => {
|
||||
const dec31 = new Date(2024, 11, 31);
|
||||
const jan1 = dateService.addDays(dec31, 1);
|
||||
|
||||
expect(jan1.getFullYear()).toBe(2025);
|
||||
expect(jan1.getMonth()).toBe(0); // January
|
||||
expect(jan1.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle reverse year transition (Jan 1 -> Dec 31)', () => {
|
||||
const jan1 = new Date(2024, 0, 1);
|
||||
const dec31 = dateService.addDays(jan1, -1);
|
||||
|
||||
expect(dec31.getFullYear()).toBe(2023);
|
||||
expect(dec31.getMonth()).toBe(11); // December
|
||||
expect(dec31.getDate()).toBe(31);
|
||||
});
|
||||
|
||||
it('should correctly calculate week bounds at year boundary', () => {
|
||||
const jan1_2024 = new Date(2024, 0, 1);
|
||||
const weekBounds = dateService.getWeekBounds(jan1_2024);
|
||||
|
||||
// Jan 1, 2024 is a Monday (week 1)
|
||||
expect(weekBounds.start.getDate()).toBe(1);
|
||||
expect(weekBounds.start.getMonth()).toBe(0);
|
||||
expect(weekBounds.start.getFullYear()).toBe(2024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DST Transition Edge Cases', () => {
|
||||
it('should handle spring DST transition (CET -> CEST)', () => {
|
||||
// Last Sunday of March 2024: March 31, 02:00 -> 03:00
|
||||
const beforeDST = new Date(2024, 2, 31, 1, 30); // 01:30 CET
|
||||
const afterDST = new Date(2024, 2, 31, 3, 30); // 03:30 CEST
|
||||
|
||||
expect(dateService.isValid(beforeDST)).toBe(true);
|
||||
expect(dateService.isValid(afterDST)).toBe(true);
|
||||
|
||||
// The hour 02:00-03:00 doesn't exist!
|
||||
const nonExistentTime = new Date(2024, 2, 31, 2, 30);
|
||||
// JavaScript auto-adjusts this
|
||||
expect(nonExistentTime.getHours()).not.toBe(2);
|
||||
});
|
||||
|
||||
it('should handle fall DST transition (CEST -> CET)', () => {
|
||||
// Last Sunday of October 2024: October 27, 03:00 -> 02:00
|
||||
const beforeDST = new Date(2024, 9, 27, 2, 30, 0, 0);
|
||||
const afterDST = new Date(2024, 9, 27, 3, 30, 0, 0);
|
||||
|
||||
expect(dateService.isValid(beforeDST)).toBe(true);
|
||||
expect(dateService.isValid(afterDST)).toBe(true);
|
||||
|
||||
// 02:00-03:00 exists TWICE (ambiguous hour)
|
||||
// This is handled by timezone-aware libraries
|
||||
});
|
||||
|
||||
it('should calculate duration correctly across DST', () => {
|
||||
// Event spanning DST transition
|
||||
const start = new Date(2024, 2, 31, 1, 0); // Before DST
|
||||
const end = new Date(2024, 2, 31, 4, 0); // After DST
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
|
||||
// Clock time: 3 hours, but actual duration: 2 hours (due to DST)
|
||||
// date-fns should handle this correctly
|
||||
expect(duration).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extreme Date Values', () => {
|
||||
it('should reject dates before 1900', () => {
|
||||
const oldDate = new Date(1899, 11, 31);
|
||||
expect(dateService.isWithinBounds(oldDate)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject dates after 2100', () => {
|
||||
const futureDate = new Date(2101, 0, 1);
|
||||
expect(dateService.isWithinBounds(futureDate)).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept boundary dates (1900 and 2100)', () => {
|
||||
const minDate = new Date(1900, 0, 1);
|
||||
const maxDate = new Date(2100, 11, 31);
|
||||
|
||||
expect(dateService.isWithinBounds(minDate)).toBe(true);
|
||||
expect(dateService.isWithinBounds(maxDate)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate invalid Date objects', () => {
|
||||
const invalidDate = new Date('invalid');
|
||||
expect(dateService.isValid(invalidDate)).toBe(false);
|
||||
expect(dateService.isWithinBounds(invalidDate)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
246
test/utils/DateService.midnight.test.ts
Normal file
246
test/utils/DateService.midnight.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DateService } from '../../src/utils/DateService';
|
||||
|
||||
describe('DateService - Midnight Crossing & Multi-Day Events', () => {
|
||||
const dateService = new DateService('Europe/Copenhagen');
|
||||
|
||||
describe('Midnight Crossing Events', () => {
|
||||
it('should handle event starting before midnight and ending after', () => {
|
||||
const start = new Date(2024, 0, 15, 23, 30); // Jan 15, 23:30
|
||||
const end = new Date(2024, 0, 16, 1, 30); // Jan 16, 01:30
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
expect(dateService.isSameDay(start, end)).toBe(false);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(120); // 2 hours
|
||||
});
|
||||
|
||||
it('should calculate duration correctly across midnight', () => {
|
||||
const start = new Date(2024, 0, 15, 22, 0); // 22:00
|
||||
const end = new Date(2024, 0, 16, 2, 0); // 02:00 next day
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(240); // 4 hours
|
||||
});
|
||||
|
||||
it('should handle event ending exactly at midnight', () => {
|
||||
const start = new Date(2024, 0, 15, 20, 0); // 20:00
|
||||
const end = new Date(2024, 0, 16, 0, 0); // 00:00 (midnight)
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(240); // 4 hours
|
||||
});
|
||||
|
||||
it('should handle event starting exactly at midnight', () => {
|
||||
const start = new Date(2024, 0, 15, 0, 0); // 00:00 (midnight)
|
||||
const end = new Date(2024, 0, 15, 3, 0); // 03:00 same day
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(false);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(180); // 3 hours
|
||||
});
|
||||
|
||||
it('should create date at specific time correctly across midnight', () => {
|
||||
const baseDate = new Date(2024, 0, 15);
|
||||
|
||||
// 1440 minutes = 24:00 = midnight next day
|
||||
const midnightNextDay = dateService.createDateAtTime(baseDate, 1440);
|
||||
expect(midnightNextDay.getDate()).toBe(16);
|
||||
expect(midnightNextDay.getHours()).toBe(0);
|
||||
expect(midnightNextDay.getMinutes()).toBe(0);
|
||||
|
||||
// 1500 minutes = 25:00 = 01:00 next day
|
||||
const oneAmNextDay = dateService.createDateAtTime(baseDate, 1500);
|
||||
expect(oneAmNextDay.getDate()).toBe(16);
|
||||
expect(oneAmNextDay.getHours()).toBe(1);
|
||||
expect(oneAmNextDay.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-Day Events', () => {
|
||||
it('should detect 2-day event', () => {
|
||||
const start = new Date(2024, 0, 15, 10, 0);
|
||||
const end = new Date(2024, 0, 16, 14, 0);
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(28 * 60); // 28 hours
|
||||
});
|
||||
|
||||
it('should detect 3-day event', () => {
|
||||
const start = new Date(2024, 0, 15, 9, 0);
|
||||
const end = new Date(2024, 0, 17, 17, 0);
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(56 * 60); // 56 hours
|
||||
});
|
||||
|
||||
it('should detect week-long event', () => {
|
||||
const start = new Date(2024, 0, 15, 0, 0);
|
||||
const end = new Date(2024, 0, 22, 0, 0);
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(7 * 24 * 60); // 7 days
|
||||
});
|
||||
|
||||
it('should handle month-spanning multi-day event', () => {
|
||||
const start = new Date(2024, 0, 30, 12, 0); // Jan 30
|
||||
const end = new Date(2024, 1, 2, 12, 0); // Feb 2
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
expect(start.getMonth()).toBe(0); // January
|
||||
expect(end.getMonth()).toBe(1); // February
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(3 * 24 * 60); // 3 days
|
||||
});
|
||||
|
||||
it('should handle year-spanning multi-day event', () => {
|
||||
const start = new Date(2024, 11, 30, 10, 0); // Dec 30, 2024
|
||||
const end = new Date(2025, 0, 2, 10, 0); // Jan 2, 2025
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
expect(start.getFullYear()).toBe(2024);
|
||||
expect(end.getFullYear()).toBe(2025);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(3 * 24 * 60); // 3 days
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timezone Boundary Events', () => {
|
||||
it('should handle UTC to local timezone conversion across midnight', () => {
|
||||
// Event in UTC that crosses date boundary in local timezone
|
||||
const utcStart = '2024-01-15T23:00:00Z'; // 23:00 UTC
|
||||
const utcEnd = '2024-01-16T01:00:00Z'; // 01:00 UTC next day
|
||||
|
||||
const localStart = dateService.fromUTC(utcStart);
|
||||
const localEnd = dateService.fromUTC(utcEnd);
|
||||
|
||||
// Copenhagen is UTC+1 (or UTC+2 in summer)
|
||||
// So 23:00 UTC = 00:00 or 01:00 local (midnight crossing)
|
||||
expect(localStart.getDate()).toBeGreaterThanOrEqual(15);
|
||||
expect(localEnd.getDate()).toBeGreaterThanOrEqual(16);
|
||||
|
||||
const duration = dateService.getDurationMinutes(localStart, localEnd);
|
||||
expect(duration).toBe(120); // 2 hours
|
||||
});
|
||||
|
||||
it('should preserve duration when converting UTC to local', () => {
|
||||
const utcStart = '2024-06-15T10:00:00Z';
|
||||
const utcEnd = '2024-06-15T18:00:00Z';
|
||||
|
||||
const localStart = dateService.fromUTC(utcStart);
|
||||
const localEnd = dateService.fromUTC(utcEnd);
|
||||
|
||||
const utcDuration = 8 * 60; // 8 hours
|
||||
const localDuration = dateService.getDurationMinutes(localStart, localEnd);
|
||||
|
||||
expect(localDuration).toBe(utcDuration);
|
||||
});
|
||||
|
||||
it('should handle all-day events (00:00 to 00:00 next day)', () => {
|
||||
const start = new Date(2024, 0, 15, 0, 0, 0);
|
||||
const end = new Date(2024, 0, 16, 0, 0, 0);
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(24 * 60); // 24 hours
|
||||
});
|
||||
|
||||
it('should handle multi-day all-day events', () => {
|
||||
const start = new Date(2024, 0, 15, 0, 0, 0);
|
||||
const end = new Date(2024, 0, 18, 0, 0, 0); // 3-day event
|
||||
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
|
||||
const duration = dateService.getDurationMinutes(start, end);
|
||||
expect(duration).toBe(3 * 24 * 60); // 72 hours
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases with Minutes Since Midnight', () => {
|
||||
it('should calculate minutes since midnight correctly at day boundary', () => {
|
||||
const midnight = new Date(2024, 0, 15, 0, 0);
|
||||
const beforeMidnight = new Date(2024, 0, 14, 23, 59);
|
||||
const afterMidnight = new Date(2024, 0, 15, 0, 1);
|
||||
|
||||
expect(dateService.getMinutesSinceMidnight(midnight)).toBe(0);
|
||||
expect(dateService.getMinutesSinceMidnight(beforeMidnight)).toBe(23 * 60 + 59);
|
||||
expect(dateService.getMinutesSinceMidnight(afterMidnight)).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle createDateAtTime with overflow minutes (>1440)', () => {
|
||||
const baseDate = new Date(2024, 0, 15);
|
||||
|
||||
// 1500 minutes = 25 hours = next day at 01:00
|
||||
const result = dateService.createDateAtTime(baseDate, 1500);
|
||||
|
||||
expect(result.getDate()).toBe(16); // Next day
|
||||
expect(result.getHours()).toBe(1);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle createDateAtTime with large overflow (48+ hours)', () => {
|
||||
const baseDate = new Date(2024, 0, 15);
|
||||
|
||||
// 2880 minutes = 48 hours = 2 days later
|
||||
const result = dateService.createDateAtTime(baseDate, 2880);
|
||||
|
||||
expect(result.getDate()).toBe(17); // 2 days later
|
||||
expect(result.getHours()).toBe(0);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Same Day vs Multi-Day Detection', () => {
|
||||
it('should correctly identify same-day events', () => {
|
||||
const start = new Date(2024, 0, 15, 8, 0);
|
||||
const end = new Date(2024, 0, 15, 17, 0);
|
||||
|
||||
expect(dateService.isSameDay(start, end)).toBe(true);
|
||||
expect(dateService.isMultiDay(start, end)).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly identify multi-day events', () => {
|
||||
const start = new Date(2024, 0, 15, 23, 0);
|
||||
const end = new Date(2024, 0, 16, 1, 0);
|
||||
|
||||
expect(dateService.isSameDay(start, end)).toBe(false);
|
||||
expect(dateService.isMultiDay(start, end)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle ISO string inputs for multi-day detection', () => {
|
||||
const startISO = '2024-01-15T23:00:00Z';
|
||||
const endISO = '2024-01-16T01:00:00Z';
|
||||
|
||||
// Convert UTC strings to local timezone first
|
||||
const startLocal = dateService.fromUTC(startISO);
|
||||
const endLocal = dateService.fromUTC(endISO);
|
||||
|
||||
const result = dateService.isMultiDay(startLocal, endLocal);
|
||||
|
||||
// 23:00 UTC = 00:00 CET (next day) in Copenhagen
|
||||
// So this IS a multi-day event in local time
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed Date and string inputs', () => {
|
||||
const startDate = new Date(2024, 0, 15, 10, 0);
|
||||
const endISO = '2024-01-16T10:00:00Z';
|
||||
|
||||
const result = dateService.isMultiDay(startDate, endISO);
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
376
test/utils/DateService.validation.test.ts
Normal file
376
test/utils/DateService.validation.test.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DateService } from '../../src/utils/DateService';
|
||||
|
||||
describe('DateService - Validation', () => {
|
||||
const dateService = new DateService('Europe/Copenhagen');
|
||||
|
||||
describe('isValid() - Basic Date Validation', () => {
|
||||
it('should validate normal dates', () => {
|
||||
const validDate = new Date(2024, 5, 15);
|
||||
expect(dateService.isValid(validDate)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid date strings', () => {
|
||||
const invalidDate = new Date('not a date');
|
||||
expect(dateService.isValid(invalidDate)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject NaN dates', () => {
|
||||
const nanDate = new Date(NaN);
|
||||
expect(dateService.isValid(nanDate)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject dates created from invalid input', () => {
|
||||
const invalidDate = new Date('2024-13-45'); // Invalid month and day
|
||||
expect(dateService.isValid(invalidDate)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate leap year dates', () => {
|
||||
const leapDay = new Date(2024, 1, 29); // Feb 29, 2024
|
||||
expect(dateService.isValid(leapDay)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect invalid leap year dates', () => {
|
||||
const invalidLeapDay = new Date(2023, 1, 29); // Feb 29, 2023 (not leap year)
|
||||
// JavaScript auto-corrects this to March 1
|
||||
expect(invalidLeapDay.getMonth()).toBe(2); // March
|
||||
expect(invalidLeapDay.getDate()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWithinBounds() - Date Range Validation', () => {
|
||||
it('should accept dates within bounds (1900-2100)', () => {
|
||||
const dates = [
|
||||
new Date(1900, 0, 1), // Min bound
|
||||
new Date(1950, 6, 15),
|
||||
new Date(2000, 0, 1),
|
||||
new Date(2024, 5, 15),
|
||||
new Date(2100, 11, 31) // Max bound
|
||||
];
|
||||
|
||||
dates.forEach(date => {
|
||||
expect(dateService.isWithinBounds(date)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject dates before 1900', () => {
|
||||
const tooEarly = [
|
||||
new Date(1899, 11, 31),
|
||||
new Date(1800, 0, 1),
|
||||
new Date(1000, 6, 15)
|
||||
];
|
||||
|
||||
tooEarly.forEach(date => {
|
||||
expect(dateService.isWithinBounds(date)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject dates after 2100', () => {
|
||||
const tooLate = [
|
||||
new Date(2101, 0, 1),
|
||||
new Date(2200, 6, 15),
|
||||
new Date(3000, 0, 1)
|
||||
];
|
||||
|
||||
tooLate.forEach(date => {
|
||||
expect(dateService.isWithinBounds(date)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid dates', () => {
|
||||
const invalidDate = new Date('invalid');
|
||||
expect(dateService.isWithinBounds(invalidDate)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle boundary dates exactly', () => {
|
||||
const minDate = new Date(1900, 0, 1, 0, 0, 0);
|
||||
const maxDate = new Date(2100, 11, 31, 23, 59, 59);
|
||||
|
||||
expect(dateService.isWithinBounds(minDate)).toBe(true);
|
||||
expect(dateService.isWithinBounds(maxDate)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidRange() - Date Range Validation', () => {
|
||||
it('should validate correct date ranges', () => {
|
||||
const start = new Date(2024, 0, 15);
|
||||
const end = new Date(2024, 0, 20);
|
||||
|
||||
expect(dateService.isValidRange(start, end)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept equal start and end dates', () => {
|
||||
const date = new Date(2024, 0, 15, 10, 0);
|
||||
|
||||
expect(dateService.isValidRange(date, date)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject reversed date ranges', () => {
|
||||
const start = new Date(2024, 0, 20);
|
||||
const end = new Date(2024, 0, 15);
|
||||
|
||||
expect(dateService.isValidRange(start, end)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject ranges with invalid start date', () => {
|
||||
const invalidStart = new Date('invalid');
|
||||
const validEnd = new Date(2024, 0, 20);
|
||||
|
||||
expect(dateService.isValidRange(invalidStart, validEnd)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject ranges with invalid end date', () => {
|
||||
const validStart = new Date(2024, 0, 15);
|
||||
const invalidEnd = new Date('invalid');
|
||||
|
||||
expect(dateService.isValidRange(validStart, invalidEnd)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate ranges across year boundaries', () => {
|
||||
const start = new Date(2024, 11, 30);
|
||||
const end = new Date(2025, 0, 5);
|
||||
|
||||
expect(dateService.isValidRange(start, end)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate multi-year ranges', () => {
|
||||
const start = new Date(2020, 0, 1);
|
||||
const end = new Date(2024, 11, 31);
|
||||
|
||||
expect(dateService.isValidRange(start, end)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDate() - Comprehensive Validation', () => {
|
||||
it('should validate normal dates without options', () => {
|
||||
const date = new Date(2024, 5, 15);
|
||||
const result = dateService.validateDate(date);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject invalid dates', () => {
|
||||
const invalidDate = new Date('invalid');
|
||||
const result = dateService.validateDate(invalidDate);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Invalid date');
|
||||
});
|
||||
|
||||
it('should reject out-of-bounds dates', () => {
|
||||
const tooEarly = new Date(1899, 0, 1);
|
||||
const result = dateService.validateDate(tooEarly);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('out of bounds');
|
||||
});
|
||||
|
||||
it('should validate future dates with requireFuture option', () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 10);
|
||||
|
||||
const result = dateService.validateDate(futureDate, { requireFuture: true });
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject past dates with requireFuture option', () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 10);
|
||||
|
||||
const result = dateService.validateDate(pastDate, { requireFuture: true });
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('future');
|
||||
});
|
||||
|
||||
it('should validate past dates with requirePast option', () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 10);
|
||||
|
||||
const result = dateService.validateDate(pastDate, { requirePast: true });
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject future dates with requirePast option', () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 10);
|
||||
|
||||
const result = dateService.validateDate(futureDate, { requirePast: true });
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('past');
|
||||
});
|
||||
|
||||
it('should validate dates with minDate constraint', () => {
|
||||
const minDate = new Date(2024, 0, 1);
|
||||
const testDate = new Date(2024, 6, 15);
|
||||
|
||||
const result = dateService.validateDate(testDate, { minDate });
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject dates before minDate', () => {
|
||||
const minDate = new Date(2024, 6, 1);
|
||||
const testDate = new Date(2024, 5, 15);
|
||||
|
||||
const result = dateService.validateDate(testDate, { minDate });
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('after');
|
||||
});
|
||||
|
||||
it('should validate dates with maxDate constraint', () => {
|
||||
const maxDate = new Date(2024, 11, 31);
|
||||
const testDate = new Date(2024, 6, 15);
|
||||
|
||||
const result = dateService.validateDate(testDate, { maxDate });
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject dates after maxDate', () => {
|
||||
const maxDate = new Date(2024, 6, 31);
|
||||
const testDate = new Date(2024, 7, 15);
|
||||
|
||||
const result = dateService.validateDate(testDate, { maxDate });
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('before');
|
||||
});
|
||||
|
||||
it('should validate dates with both minDate and maxDate', () => {
|
||||
const minDate = new Date(2024, 0, 1);
|
||||
const maxDate = new Date(2024, 11, 31);
|
||||
const testDate = new Date(2024, 6, 15);
|
||||
|
||||
const result = dateService.validateDate(testDate, { minDate, maxDate });
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject dates outside min/max range', () => {
|
||||
const minDate = new Date(2024, 6, 1);
|
||||
const maxDate = new Date(2024, 6, 31);
|
||||
const testDate = new Date(2024, 7, 15);
|
||||
|
||||
const result = dateService.validateDate(testDate, { minDate, maxDate });
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid Date Scenarios', () => {
|
||||
it('should handle February 30 (auto-corrects to March)', () => {
|
||||
const invalidDate = new Date(2024, 1, 30); // Tries Feb 30, 2024
|
||||
|
||||
// JavaScript auto-corrects to March
|
||||
expect(invalidDate.getMonth()).toBe(2); // March
|
||||
expect(invalidDate.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle month overflow (month 13)', () => {
|
||||
const date = new Date(2024, 12, 1); // Month 13 = January next year
|
||||
|
||||
expect(date.getFullYear()).toBe(2025);
|
||||
expect(date.getMonth()).toBe(0); // January
|
||||
});
|
||||
|
||||
it('should handle negative months', () => {
|
||||
const date = new Date(2024, -1, 1); // Month -1 = December previous year
|
||||
|
||||
expect(date.getFullYear()).toBe(2023);
|
||||
expect(date.getMonth()).toBe(11); // December
|
||||
});
|
||||
|
||||
it('should handle day 0 (last day of previous month)', () => {
|
||||
const date = new Date(2024, 1, 0); // Day 0 of Feb = Last day of Jan
|
||||
|
||||
expect(date.getMonth()).toBe(0); // January
|
||||
expect(date.getDate()).toBe(31);
|
||||
});
|
||||
|
||||
it('should handle negative days', () => {
|
||||
const date = new Date(2024, 1, -1); // Day -1 of Feb
|
||||
|
||||
expect(date.getMonth()).toBe(0); // January
|
||||
expect(date.getDate()).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timezone-aware Validation', () => {
|
||||
it('should validate UTC dates converted to local timezone', () => {
|
||||
const utcString = '2024-06-15T12:00:00Z';
|
||||
const localDate = dateService.fromUTC(utcString);
|
||||
|
||||
expect(dateService.isValid(localDate)).toBe(true);
|
||||
expect(dateService.isWithinBounds(localDate)).toBe(true);
|
||||
});
|
||||
|
||||
it('should maintain validation across timezone conversion', () => {
|
||||
const localDate = new Date(2024, 6, 15, 12, 0);
|
||||
const utcString = dateService.toUTC(localDate);
|
||||
const convertedBack = dateService.fromUTC(utcString);
|
||||
|
||||
expect(dateService.isValid(convertedBack)).toBe(true);
|
||||
|
||||
// Should be same day (accounting for timezone)
|
||||
const validation = dateService.validateDate(convertedBack);
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate dates during DST transitions', () => {
|
||||
// Spring DST: March 31, 2024 in Copenhagen
|
||||
const dstDate = new Date(2024, 2, 31, 2, 30); // Non-existent hour
|
||||
|
||||
// JavaScript handles this, should still be valid
|
||||
expect(dateService.isValid(dstDate)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Case Validation Combinations', () => {
|
||||
it('should reject invalid date even with lenient options', () => {
|
||||
const invalidDate = new Date('completely invalid');
|
||||
const result = dateService.validateDate(invalidDate, {
|
||||
minDate: new Date(1900, 0, 1),
|
||||
maxDate: new Date(2100, 11, 31)
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Invalid date');
|
||||
});
|
||||
|
||||
it('should validate boundary dates with constraints', () => {
|
||||
const boundaryDate = new Date(1900, 0, 1);
|
||||
const result = dateService.validateDate(boundaryDate, {
|
||||
minDate: new Date(1900, 0, 1)
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should provide meaningful error messages', () => {
|
||||
const testCases = [
|
||||
{ date: new Date('invalid'), expectedError: 'Invalid date' },
|
||||
{ date: new Date(1800, 0, 1), expectedError: 'bounds' },
|
||||
];
|
||||
|
||||
testCases.forEach(({ date, expectedError }) => {
|
||||
const result = dateService.validateDate(date);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate leap year boundaries correctly', () => {
|
||||
const leapYearEnd = new Date(2024, 1, 29); // Last day of Feb in leap year
|
||||
const nonLeapYearEnd = new Date(2023, 1, 28); // Last day of Feb in non-leap year
|
||||
|
||||
expect(dateService.validateDate(leapYearEnd).valid).toBe(true);
|
||||
expect(dateService.validateDate(nonLeapYearEnd).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
test/utils/OverlapDetector.test.ts
Normal file
287
test/utils/OverlapDetector.test.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { OverlapDetector } from '../../src/utils/OverlapDetector';
|
||||
import { CalendarEvent } from '../../src/types/CalendarTypes';
|
||||
|
||||
describe('OverlapDetector', () => {
|
||||
let detector: OverlapDetector;
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new OverlapDetector();
|
||||
});
|
||||
|
||||
// Helper function to create test events
|
||||
const createEvent = (id: string, startHour: number, startMin: number, endHour: number, endMin: number): CalendarEvent => {
|
||||
const start = new Date(2024, 0, 1, startHour, startMin);
|
||||
const end = new Date(2024, 0, 1, endHour, endMin);
|
||||
return {
|
||||
id,
|
||||
title: `Event ${id}`,
|
||||
start,
|
||||
end,
|
||||
type: 'meeting',
|
||||
allDay: false,
|
||||
syncStatus: 'synced'
|
||||
};
|
||||
};
|
||||
|
||||
describe('resolveOverlap', () => {
|
||||
it('should detect no overlap when events do not overlap', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00
|
||||
const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect overlap when events partially overlap', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 30); // 09:00-10:30
|
||||
const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
expect(overlaps).toHaveLength(1);
|
||||
expect(overlaps[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should detect overlap when one event contains another', () => {
|
||||
const event1 = createEvent('1', 9, 0, 12, 0); // 09:00-12:00
|
||||
const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
expect(overlaps).toHaveLength(1);
|
||||
expect(overlaps[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should detect overlap when events have same start time', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00
|
||||
const event2 = createEvent('2', 9, 0, 10, 30); // 09:00-10:30
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
expect(overlaps).toHaveLength(1);
|
||||
expect(overlaps[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should detect overlap when events have same end time', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00
|
||||
const event2 = createEvent('2', 9, 30, 10, 0); // 09:30-10:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
expect(overlaps).toHaveLength(1);
|
||||
expect(overlaps[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should detect multiple overlapping events', () => {
|
||||
const event1 = createEvent('1', 9, 0, 11, 0); // 09:00-11:00
|
||||
const event2 = createEvent('2', 9, 30, 10, 30); // 09:30-10:30
|
||||
const event3 = createEvent('3', 10, 0, 11, 30); // 10:00-11:30
|
||||
const event4 = createEvent('4', 12, 0, 13, 0); // 12:00-13:00 (no overlap)
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2, event3, event4]);
|
||||
|
||||
expect(overlaps).toHaveLength(2);
|
||||
expect(overlaps.map(e => e.id)).toEqual(['2', '3']);
|
||||
});
|
||||
|
||||
it('should handle edge case where event ends exactly when another starts', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00
|
||||
const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
// Events that touch at boundaries should NOT overlap
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle events with 1-minute overlap', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 1); // 09:00-10:01
|
||||
const event2 = createEvent('2', 10, 0, 11, 0); // 10:00-11:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
expect(overlaps).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decorateWithStackLinks', () => {
|
||||
it('should return empty result when no overlapping events', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 0);
|
||||
|
||||
const result = detector.decorateWithStackLinks(event1, []);
|
||||
|
||||
expect(result.overlappingEvents).toHaveLength(0);
|
||||
expect(result.stackLinks.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should assign stack levels based on start time order', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 30); // 09:00-10:30
|
||||
const event2 = createEvent('2', 9, 30, 11, 0); // 09:30-11:00
|
||||
|
||||
const result = detector.decorateWithStackLinks(event1, [event2]);
|
||||
|
||||
expect(result.stackLinks.size).toBe(2);
|
||||
|
||||
const link1 = result.stackLinks.get('1' as any);
|
||||
const link2 = result.stackLinks.get('2' as any);
|
||||
|
||||
expect(link1?.stackLevel).toBe(0);
|
||||
expect(link1?.prev).toBeUndefined();
|
||||
expect(link1?.next).toBe('2');
|
||||
|
||||
expect(link2?.stackLevel).toBe(1);
|
||||
expect(link2?.prev).toBe('1');
|
||||
expect(link2?.next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create linked chain for multiple overlapping events', () => {
|
||||
const event1 = createEvent('1', 9, 0, 11, 0); // 09:00-11:00
|
||||
const event2 = createEvent('2', 9, 30, 10, 30); // 09:30-10:30
|
||||
const event3 = createEvent('3', 10, 0, 11, 30); // 10:00-11:30
|
||||
|
||||
const result = detector.decorateWithStackLinks(event1, [event2, event3]);
|
||||
|
||||
expect(result.stackLinks.size).toBe(3);
|
||||
|
||||
const link1 = result.stackLinks.get('1' as any);
|
||||
const link2 = result.stackLinks.get('2' as any);
|
||||
const link3 = result.stackLinks.get('3' as any);
|
||||
|
||||
// Check chain: 1 -> 2 -> 3
|
||||
expect(link1?.stackLevel).toBe(0);
|
||||
expect(link1?.prev).toBeUndefined();
|
||||
expect(link1?.next).toBe('2');
|
||||
|
||||
expect(link2?.stackLevel).toBe(1);
|
||||
expect(link2?.prev).toBe('1');
|
||||
expect(link2?.next).toBe('3');
|
||||
|
||||
expect(link3?.stackLevel).toBe(2);
|
||||
expect(link3?.prev).toBe('2');
|
||||
expect(link3?.next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle events with same start time', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00
|
||||
const event2 = createEvent('2', 9, 0, 10, 30); // 09:00-10:30
|
||||
|
||||
const result = detector.decorateWithStackLinks(event1, [event2]);
|
||||
|
||||
const link1 = result.stackLinks.get('1' as any);
|
||||
const link2 = result.stackLinks.get('2' as any);
|
||||
|
||||
// Both start at same time - order may vary but levels should be 0 and 1
|
||||
const levels = [link1?.stackLevel, link2?.stackLevel].sort();
|
||||
expect(levels).toEqual([0, 1]);
|
||||
|
||||
// Verify they are linked together
|
||||
expect(result.stackLinks.size).toBe(2);
|
||||
});
|
||||
|
||||
it('KNOWN ISSUE: should NOT stack events that do not overlap', () => {
|
||||
// This test documents the current bug
|
||||
const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00
|
||||
const event2 = createEvent('2', 9, 30, 10, 30); // 09:30-10:30 (overlaps with 1)
|
||||
const event3 = createEvent('3', 11, 0, 12, 0); // 11:00-12:00 (NO overlap with 1 or 2)
|
||||
|
||||
const result = detector.decorateWithStackLinks(event1, [event2, event3]);
|
||||
|
||||
const link3 = result.stackLinks.get('3' as any);
|
||||
|
||||
// CURRENT BEHAVIOR (BUG): Event 3 gets stackLevel 2
|
||||
expect(link3?.stackLevel).toBe(2);
|
||||
|
||||
// EXPECTED BEHAVIOR: Event 3 should get stackLevel 0 since it doesn't overlap
|
||||
// expect(link3?.stackLevel).toBe(0);
|
||||
// expect(link3?.prev).toBeUndefined();
|
||||
// expect(link3?.next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('KNOWN ISSUE: should reuse stack levels when possible', () => {
|
||||
// This test documents another aspect of the bug
|
||||
const event1 = createEvent('1', 9, 0, 10, 0); // 09:00-10:00
|
||||
const event2 = createEvent('2', 10, 30, 11, 30); // 10:30-11:30 (NO overlap)
|
||||
const event3 = createEvent('3', 12, 0, 13, 0); // 12:00-13:00 (NO overlap)
|
||||
|
||||
const result = detector.decorateWithStackLinks(event1, [event2, event3]);
|
||||
|
||||
const link1 = result.stackLinks.get('1' as any);
|
||||
const link2 = result.stackLinks.get('2' as any);
|
||||
const link3 = result.stackLinks.get('3' as any);
|
||||
|
||||
// CURRENT BEHAVIOR (BUG): All get different stack levels
|
||||
expect(link1?.stackLevel).toBe(0);
|
||||
expect(link2?.stackLevel).toBe(1);
|
||||
expect(link3?.stackLevel).toBe(2);
|
||||
|
||||
// EXPECTED BEHAVIOR: All should reuse level 0 since none overlap
|
||||
// expect(link1?.stackLevel).toBe(0);
|
||||
// expect(link2?.stackLevel).toBe(0);
|
||||
// expect(link3?.stackLevel).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle complex overlapping pattern correctly', () => {
|
||||
// Event 1: 09:00-11:00 (base)
|
||||
// Event 2: 09:30-10:30 (overlaps with 1)
|
||||
// Event 3: 10:00-11:30 (overlaps with 1 and 2)
|
||||
// Event 4: 11:00-12:00 (overlaps with 3 only)
|
||||
|
||||
const event1 = createEvent('1', 9, 0, 11, 0);
|
||||
const event2 = createEvent('2', 9, 30, 10, 30);
|
||||
const event3 = createEvent('3', 10, 0, 11, 30);
|
||||
const event4 = createEvent('4', 11, 0, 12, 0);
|
||||
|
||||
const result = detector.decorateWithStackLinks(event1, [event2, event3, event4]);
|
||||
|
||||
expect(result.stackLinks.size).toBe(4);
|
||||
|
||||
// All events are linked in one chain (current behavior)
|
||||
const link1 = result.stackLinks.get('1' as any);
|
||||
const link2 = result.stackLinks.get('2' as any);
|
||||
const link3 = result.stackLinks.get('3' as any);
|
||||
const link4 = result.stackLinks.get('4' as any);
|
||||
|
||||
expect(link1?.stackLevel).toBe(0);
|
||||
expect(link2?.stackLevel).toBe(1);
|
||||
expect(link3?.stackLevel).toBe(2);
|
||||
expect(link4?.stackLevel).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero-duration events', () => {
|
||||
const event1 = createEvent('1', 9, 0, 9, 0); // 09:00-09:00
|
||||
const event2 = createEvent('2', 9, 0, 10, 0); // 09:00-10:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
// Zero-duration event at start of another should not overlap
|
||||
expect(overlaps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle events spanning multiple hours', () => {
|
||||
const event1 = createEvent('1', 8, 0, 17, 0); // 08:00-17:00 (9 hours)
|
||||
const event2 = createEvent('2', 12, 0, 13, 0); // 12:00-13:00
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, [event2]);
|
||||
|
||||
expect(overlaps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle many events in same time slot', () => {
|
||||
const event1 = createEvent('1', 9, 0, 10, 0);
|
||||
const events = [
|
||||
createEvent('2', 9, 0, 10, 0),
|
||||
createEvent('3', 9, 0, 10, 0),
|
||||
createEvent('4', 9, 0, 10, 0),
|
||||
createEvent('5', 9, 0, 10, 0)
|
||||
];
|
||||
|
||||
const overlaps = detector.resolveOverlap(event1, events);
|
||||
|
||||
expect(overlaps).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue