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
81
.workbench/stacking-test-desc.txt
Normal file
81
.workbench/stacking-test-desc.txt
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
## Testplan – Stack link (`data-stack-link`) & z-index
|
||||||
|
|
||||||
|
|
||||||
|
### A. Regler (krav som testes)
|
||||||
|
- **SL1**: Hvert event har en gyldig `data-stack-link` JSON med felterne `{ prev, stackLevel }`.
|
||||||
|
- **SL2**: `stackLevel` ≥ 1 og heltal. Nederste event i en stack har `prev = null` og `stackLevel = 1`.
|
||||||
|
- **SL3**: `prev` refererer til **eksisterende** event-ID i **samme lane** (ingen cross-lane links).
|
||||||
|
- **SL4**: Kæden er **acyklisk** (ingen loops) og uden “dangling” referencer.
|
||||||
|
- **SL5**: For en given stack er levels **kontiguøse** (1..N uden huller).
|
||||||
|
- **SL6**: Ved **flyt/resize/slet** genberegnes stack-links deterministisk (samme input ⇒ samme output).
|
||||||
|
- **Z1**: z-index er en **strengt voksende funktion** af `stackLevel` (fx `zIndex = base + stackLevel`).
|
||||||
|
- **Z2**: For overlappende events i **samme lane** gælder: højere `stackLevel` **renderes visuelt ovenpå** lavere level (ingen tekst skjules af et lavere level).
|
||||||
|
- **Z3**: z-index må **ikke** afhænge af DOM-indsættelsesrækkefølge—kun af `stackLevel` (og evt. lane-offset).
|
||||||
|
- **Z4** (valgfrit): På tværs af lanes kan systemet enten bruge samme base eller lane-baseret offset (fx `zIndex = lane*100 + stackLevel`). Uanset valg må events i **samme lane** aldrig blive skjult af events i en **anden** lane, når de overlapper visuelt.
|
||||||
|
|
||||||
|
|
||||||
|
### B. Unit tests (logik for stack link)
|
||||||
|
1. **Basestack**
|
||||||
|
*Givet* en enkelt event A 10:00–11:00, *Når* stack beregnes, *Så* `A.prev=null` og `A.stackLevel=1` (SL2).
|
||||||
|
2. **Simpel overlap**
|
||||||
|
*Givet* A 10:00–13:00 og B 10:45–11:15 i samme lane, *Når* stack beregnes, *Så* `B.prev='A'` og `B.stackLevel=2` (SL1–SL3).
|
||||||
|
3. **Fler-leddet overlap**
|
||||||
|
*Givet* A 10–13, B 10:45–11:15, C 11:00–11:30, *Når* stack beregnes, *Så* `B.stackLevel=2`, `C.stackLevel≥2`, ingen huller i levels (SL5).
|
||||||
|
4. **Ingen overlap**
|
||||||
|
*Givet* A 10:00–11:00 og B 11:30–12:00 i samme lane, *Når* stack beregnes, *Så* `A.stackLevel=1`, `B.stackLevel=1`, `prev=null` for begge (SL2).
|
||||||
|
5. **Cross-lane isolation**
|
||||||
|
*Givet* A(lane1) 10–13 og B(lane2) 10:15–11:00, *Når* stack beregnes, *Så* `B.prev` **må ikke** pege på A (SL3).
|
||||||
|
6. **Acyklisk garanti**
|
||||||
|
*Givet* en vilkårlig mængde overlappende events, *Når* stack beregnes, *Så* kan traversal fra top → `prev` aldrig besøge samme ID to gange (SL4).
|
||||||
|
7. **Sletning i kæde**
|
||||||
|
*Givet* A→B→C (`prev`-kæde), *Når* B slettes, *Så* peger C.prev nu på A (eller `null` hvis A ikke findes), og levels reindekseres 1..N (SL5–SL6).
|
||||||
|
8. **Resize der fjerner overlap**
|
||||||
|
*Givet* A 10–13 og B 10:45–11:15 (stacked), *Når* B resizes til 13:00–13:30, *Så* `B.prev=null`, `B.stackLevel=1` (SL6).
|
||||||
|
9. **Determinisme**
|
||||||
|
*Givet* samme inputliste i samme sortering, *Når* stack beregnes to gange, *Så* er output (prev/stackLevel pr. event) identisk (SL6).
|
||||||
|
|
||||||
|
|
||||||
|
### C. Integration/DOM tests (z-index & rendering)
|
||||||
|
10. **Z-index mapping**
|
||||||
|
*Givet* mapping `zIndex = base + stackLevel`, *Når* tre overlappende events har levels 1,2,3, *Så* er `zIndex` hhv. stigende og uden lighed (Z1).
|
||||||
|
11. **Visuel prioritet**
|
||||||
|
*Givet* to overlappende events i samme lane med levels 1 (A) og 2 (B), *Når* kalenderen renderes, *Så* kan B’s titel læses fuldt ud, og A’s ikke dækker B (Z2).
|
||||||
|
12. **DOM-orden er irrelevant**
|
||||||
|
*Givet* to overlappende events, *Når* DOM-indsættelsesrækkefølgen byttes, *Så* er visuel orden uændret, styret af z-index (Z3).
|
||||||
|
13. **Lane-isolation**
|
||||||
|
*Givet* A(lane1, level 2) og B(lane2, level 1), *Når* de geometrisk overlapper (smal viewport), *Så* skjuler lane2 ikke lane1 i strid med reglen—afhængigt af valgt z-index strategi (Z4). Dokumentér valgt strategi.
|
||||||
|
14. **Tekst-visibility**
|
||||||
|
*Givet* N overlappende events, *Når* der renderes, *Så* er der ingen CSS-egenskaber (opacity/clip/overflow) der gør højere level mindre synlig end lavere (Z2).
|
||||||
|
|
||||||
|
|
||||||
|
### D. Scenarie-baserede tests (1–7)
|
||||||
|
15. **S1 – Overlap ovenpå**
|
||||||
|
Lunch `prev=Excursion`, `stackLevel=2`; `zIndex(Lunch) > zIndex(Excursion)`.
|
||||||
|
16. **S2 – Flere overlappende**
|
||||||
|
Lunch og Breakfast har `stackLevel≥2`; ingen huller 1..N; z-index følger levels.
|
||||||
|
17. **S3 – Side-by-side**
|
||||||
|
Overlappende events i samme lane har stigende `stackLevel`; venstre offset stiger med level; z-index følger levels.
|
||||||
|
18. **S4 – Sekvens**
|
||||||
|
For hvert overlap i sekvens: korrekt `prev` til nærmeste base; contiguøse levels; z-index stigende.
|
||||||
|
19. **S5 – <30 min ⇒ lane 2**
|
||||||
|
Lunch i lane 2; ingen `prev` der peger cross-lane; levels starter ved 1 i begge lanes; z-index valideres pr. lane.
|
||||||
|
20. **S6 – Stack + lane**
|
||||||
|
Lane 1: Excursion & Breakfast (levels 1..N). Lane 2: Lunch (level 1). Ingen cross-lane `prev`. Z-index korrekt i lane 1.
|
||||||
|
21. **S7 – Frivillig lane 2**
|
||||||
|
Events i lane 2 har egne levels startende på 1; z-index følger levels i hver lane.
|
||||||
|
|
||||||
|
|
||||||
|
### E. Edge cases
|
||||||
|
22. **Samme starttid**
|
||||||
|
To events med identisk start i samme lane fordeles deterministisk: det først behandlede bliver base (`level=1`), det næste `level=2`. Z-index følger.
|
||||||
|
23. **Mange levels**
|
||||||
|
*Givet* 6 overlappende events, *Når* der renderes, *Så* er levels 1..6 uden huller og z-index 6 er visuelt øverst.
|
||||||
|
24. **Ugyldigt JSON**
|
||||||
|
*Givet* en defekt `data-stack-link`, *Når* komponenten loader, *Så* logges fejl og stack genberegnes fra start/end (self-healing), hvorefter valid `data-stack-link` skrives (SL1, SL6).
|
||||||
|
|
||||||
|
|
||||||
|
### F. Implementationsnoter (hjælp til test)
|
||||||
|
- Z-index funktion bør være **enkel og auditérbar**, fx: `zIndex = 100 + stackLevel` (samme lane) eller `zIndex = lane*100 + stackLevel` (multi-lane isolation).
|
||||||
|
- Test for acykliskhed: lav traversal fra hver node: gentagen ID ⇒ fejl.
|
||||||
|
- Test for contiguity: hent alle `stackLevel` i en stack, sortér, forvent `[1..N]` uden huller.
|
||||||
|
- Test for cross-lane: sammenlign `event.dataset.lane` for `id` og dets `prev`—de skal være ens.
|
||||||
|
|
@ -1861,8 +1861,8 @@
|
||||||
{
|
{
|
||||||
"id": "144",
|
"id": "144",
|
||||||
"title": "Team Standup",
|
"title": "Team Standup",
|
||||||
"start": "2025-09-29T05:00:00Z",
|
"start": "2025-09-29T07:30:00Z",
|
||||||
"end": "2025-09-29T05:30:00Z",
|
"end": "2025-09-29T08:30:00Z",
|
||||||
"type": "meeting",
|
"type": "meeting",
|
||||||
"allDay": false,
|
"allDay": false,
|
||||||
"syncStatus": "synced",
|
"syncStatus": "synced",
|
||||||
|
|
@ -1874,7 +1874,7 @@
|
||||||
{
|
{
|
||||||
"id": "145",
|
"id": "145",
|
||||||
"title": "Månedlig Planlægning",
|
"title": "Månedlig Planlægning",
|
||||||
"start": "2025-09-29T06:00:00Z",
|
"start": "2025-09-29T07:00:00Z",
|
||||||
"end": "2025-09-29T08:00:00Z",
|
"end": "2025-09-29T08:00:00Z",
|
||||||
"type": "meeting",
|
"type": "meeting",
|
||||||
"allDay": false,
|
"allDay": false,
|
||||||
|
|
@ -1887,8 +1887,8 @@
|
||||||
{
|
{
|
||||||
"id": "146",
|
"id": "146",
|
||||||
"title": "Performance Test",
|
"title": "Performance Test",
|
||||||
"start": "2025-09-29T10:00:00Z",
|
"start": "2025-09-29T09:00:00Z",
|
||||||
"end": "2025-09-29T12:00:00Z",
|
"end": "2025-09-29T10:00:00Z",
|
||||||
"type": "work",
|
"type": "work",
|
||||||
"allDay": false,
|
"allDay": false,
|
||||||
"syncStatus": "synced",
|
"syncStatus": "synced",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { calendarConfig } from '../core/CalendarConfig';
|
||||||
import { TimeFormatter } from '../utils/TimeFormatter';
|
import { TimeFormatter } from '../utils/TimeFormatter';
|
||||||
import { PositionUtils } from '../utils/PositionUtils';
|
import { PositionUtils } from '../utils/PositionUtils';
|
||||||
import { EventLayout } from '../utils/AllDayLayoutEngine';
|
import { EventLayout } from '../utils/AllDayLayoutEngine';
|
||||||
|
import { DateService } from '../utils/DateService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for event DOM elements
|
* Abstract base class for event DOM elements
|
||||||
|
|
@ -10,9 +11,12 @@ import { EventLayout } from '../utils/AllDayLayoutEngine';
|
||||||
export abstract class BaseEventElement {
|
export abstract class BaseEventElement {
|
||||||
protected element: HTMLElement;
|
protected element: HTMLElement;
|
||||||
protected event: CalendarEvent;
|
protected event: CalendarEvent;
|
||||||
|
protected dateService: DateService;
|
||||||
|
|
||||||
protected constructor(event: CalendarEvent) {
|
protected constructor(event: CalendarEvent) {
|
||||||
this.event = event;
|
this.event = event;
|
||||||
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
|
this.dateService = new DateService(timezone);
|
||||||
this.element = this.createElement();
|
this.element = this.createElement();
|
||||||
this.setDataAttributes();
|
this.setDataAttributes();
|
||||||
}
|
}
|
||||||
|
|
@ -28,8 +32,8 @@ export abstract class BaseEventElement {
|
||||||
protected setDataAttributes(): void {
|
protected setDataAttributes(): void {
|
||||||
this.element.dataset.eventId = this.event.id;
|
this.element.dataset.eventId = this.event.id;
|
||||||
this.element.dataset.title = this.event.title;
|
this.element.dataset.title = this.event.title;
|
||||||
this.element.dataset.start = this.event.start.toISOString();
|
this.element.dataset.start = this.dateService.toUTC(this.event.start);
|
||||||
this.element.dataset.end = this.event.end.toISOString();
|
this.element.dataset.end = this.dateService.toUTC(this.event.end);
|
||||||
this.element.dataset.type = this.event.type;
|
this.element.dataset.type = this.event.type;
|
||||||
this.element.dataset.duration = this.event.metadata?.duration?.toString() || '60';
|
this.element.dataset.duration = this.event.metadata?.duration?.toString() || '60';
|
||||||
}
|
}
|
||||||
|
|
@ -245,8 +249,8 @@ export class SwpAllDayEventElement extends BaseEventElement {
|
||||||
*/
|
*/
|
||||||
private setAllDayAttributes(): void {
|
private setAllDayAttributes(): void {
|
||||||
this.element.dataset.allday = "true";
|
this.element.dataset.allday = "true";
|
||||||
this.element.dataset.start = this.event.start.toISOString();
|
this.element.dataset.start = this.dateService.toUTC(this.event.start);
|
||||||
this.element.dataset.end = this.event.end.toISOString();
|
this.element.dataset.end = this.dateService.toUTC(this.event.end);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// All-day row height management and animations
|
// All-day row height management and animations
|
||||||
|
|
||||||
import { eventBus } from '../core/EventBus';
|
import { eventBus } from '../core/EventBus';
|
||||||
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
|
import { ALL_DAY_CONSTANTS, calendarConfig } from '../core/CalendarConfig';
|
||||||
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
|
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
|
||||||
import { AllDayLayoutEngine, EventLayout } from '../utils/AllDayLayoutEngine';
|
import { AllDayLayoutEngine, EventLayout } from '../utils/AllDayLayoutEngine';
|
||||||
import { ColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
|
import { ColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
|
||||||
|
|
@ -18,6 +18,7 @@ import { DragOffset, MousePosition } from '../types/DragDropTypes';
|
||||||
import { CoreEvents } from '../constants/CoreEvents';
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
import { EventManager } from './EventManager';
|
import { EventManager } from './EventManager';
|
||||||
import { differenceInCalendarDays } from 'date-fns';
|
import { differenceInCalendarDays } from 'date-fns';
|
||||||
|
import { DateService } from '../utils/DateService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AllDayManager - Handles all-day row height animations and management
|
* AllDayManager - Handles all-day row height animations and management
|
||||||
|
|
@ -26,6 +27,7 @@ import { differenceInCalendarDays } from 'date-fns';
|
||||||
export class AllDayManager {
|
export class AllDayManager {
|
||||||
private allDayEventRenderer: AllDayEventRenderer;
|
private allDayEventRenderer: AllDayEventRenderer;
|
||||||
private eventManager: EventManager;
|
private eventManager: EventManager;
|
||||||
|
private dateService: DateService;
|
||||||
|
|
||||||
private layoutEngine: AllDayLayoutEngine | null = null;
|
private layoutEngine: AllDayLayoutEngine | null = null;
|
||||||
|
|
||||||
|
|
@ -43,6 +45,8 @@ export class AllDayManager {
|
||||||
constructor(eventManager: EventManager) {
|
constructor(eventManager: EventManager) {
|
||||||
this.eventManager = eventManager;
|
this.eventManager = eventManager;
|
||||||
this.allDayEventRenderer = new AllDayEventRenderer();
|
this.allDayEventRenderer = new AllDayEventRenderer();
|
||||||
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
|
this.dateService = new DateService(timezone);
|
||||||
|
|
||||||
// Sync CSS variable with TypeScript constant to ensure consistency
|
// Sync CSS variable with TypeScript constant to ensure consistency
|
||||||
document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`);
|
document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`);
|
||||||
|
|
@ -420,9 +424,9 @@ export class AllDayManager {
|
||||||
newEndDate.setDate(newEndDate.getDate() + durationDays);
|
newEndDate.setDate(newEndDate.getDate() + durationDays);
|
||||||
newEndDate.setHours(originalEndDate.getHours(), originalEndDate.getMinutes(), originalEndDate.getSeconds(), originalEndDate.getMilliseconds());
|
newEndDate.setHours(originalEndDate.getHours(), originalEndDate.getMinutes(), originalEndDate.getSeconds(), originalEndDate.getMilliseconds());
|
||||||
|
|
||||||
// Update data attributes with new dates
|
// Update data attributes with new dates (convert to UTC)
|
||||||
dragEndEvent.draggedClone.dataset.start = newStartDate.toISOString();
|
dragEndEvent.draggedClone.dataset.start = this.dateService.toUTC(newStartDate);
|
||||||
dragEndEvent.draggedClone.dataset.end = newEndDate.toISOString();
|
dragEndEvent.draggedClone.dataset.end = this.dateService.toUTC(newEndDate);
|
||||||
|
|
||||||
const droppedEvent: CalendarEvent = {
|
const droppedEvent: CalendarEvent = {
|
||||||
id: eventId,
|
id: eventId,
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export class CalendarManager {
|
||||||
this.eventRenderer = eventRenderer;
|
this.eventRenderer = eventRenderer;
|
||||||
this.scrollManager = scrollManager;
|
this.scrollManager = scrollManager;
|
||||||
this.eventFilterManager = new EventFilterManager();
|
this.eventFilterManager = new EventFilterManager();
|
||||||
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
this.dateService = new DateService(timezone);
|
this.dateService = new DateService(timezone);
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export class EventManager {
|
||||||
|
|
||||||
constructor(eventBus: IEventBus) {
|
constructor(eventBus: IEventBus) {
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
this.dateService = new DateService(timezone);
|
this.dateService = new DateService(timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,9 +156,16 @@ export class EventManager {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Validate event dates
|
||||||
if (isNaN(event.start.getTime())) {
|
const validation = this.dateService.validateDate(event.start);
|
||||||
console.warn(`EventManager: Invalid event start date for event ${id}:`, event.start);
|
if (!validation.valid) {
|
||||||
|
console.warn(`EventManager: Invalid event start date for event ${id}:`, validation.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date range
|
||||||
|
if (!this.dateService.isValidRange(event.start, event.end)) {
|
||||||
|
console.warn(`EventManager: Invalid date range for event ${id}: start must be before end`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,10 +173,6 @@ export class EventManager {
|
||||||
event,
|
event,
|
||||||
eventDate: event.start
|
eventDate: event.start
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.warn(`EventManager: Failed to parse event date for event ${id}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ export class GridManager {
|
||||||
const weekEnd = this.getWeekEnd(this.currentDate);
|
const weekEnd = this.getWeekEnd(this.currentDate);
|
||||||
return this.dateService.formatDateRange(weekStart, weekEnd);
|
return this.dateService.formatDateRange(weekStart, weekEnd);
|
||||||
case 'month':
|
case 'month':
|
||||||
return this.currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
return this.dateService.formatMonthYear(this.currentDate);
|
||||||
default:
|
default:
|
||||||
const defaultWeekStart = this.getISOWeekStart(this.currentDate);
|
const defaultWeekStart = this.getISOWeekStart(this.currentDate);
|
||||||
const defaultWeekEnd = this.getWeekEnd(this.currentDate);
|
const defaultWeekEnd = this.getWeekEnd(this.currentDate);
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,16 @@ export class NavigationManager {
|
||||||
|
|
||||||
// Validate date before processing
|
// Validate date before processing
|
||||||
if (!dateFromEvent) {
|
if (!dateFromEvent) {
|
||||||
|
console.warn('NavigationManager: No date provided in DATE_CHANGED event');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetDate = new Date(dateFromEvent);
|
const targetDate = new Date(dateFromEvent);
|
||||||
if (isNaN(targetDate.getTime())) {
|
|
||||||
|
// Use DateService validation
|
||||||
|
const validation = this.dateService.validateDate(targetDate);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.warn('NavigationManager: Invalid date received:', validation.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export class WorkHoursManager {
|
||||||
private workSchedule: WorkScheduleConfig;
|
private workSchedule: WorkScheduleConfig;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
this.dateService = new DateService(timezone);
|
this.dateService = new DateService(timezone);
|
||||||
|
|
||||||
// Default work schedule - will be loaded from JSON later
|
// Default work schedule - will be loaded from JSON later
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export class DateEventRenderer implements EventRendererStrategy {
|
||||||
private dateService: DateService;
|
private dateService: DateService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
this.dateService = new DateService(timezone);
|
this.dateService = new DateService(timezone);
|
||||||
this.setupDragEventListeners();
|
this.setupDragEventListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +102,7 @@ export class DateEventRenderer implements EventRendererStrategy {
|
||||||
|
|
||||||
private applyDragStyling(element: HTMLElement): void {
|
private applyDragStyling(element: HTMLElement): void {
|
||||||
element.classList.add('dragging');
|
element.classList.add('dragging');
|
||||||
|
element.style.removeProperty("margin-left");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -174,8 +175,9 @@ export class DateEventRenderer implements EventRendererStrategy {
|
||||||
endDate = this.dateService.addDays(endDate, extraDays);
|
endDate = this.dateService.addDays(endDate, extraDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.dataset.start = startDate.toISOString();
|
// Convert to UTC before storing as ISO string
|
||||||
element.dataset.end = endDate.toISOString();
|
element.dataset.start = this.dateService.toUTC(startDate);
|
||||||
|
element.dataset.end = this.dateService.toUTC(endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export class GridRenderer {
|
||||||
private dateService: DateService;
|
private dateService: DateService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
this.dateService = new DateService(timezone);
|
this.dateService = new DateService(timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export class WeekViewStrategy implements ViewStrategy {
|
||||||
private styleManager: GridStyleManager;
|
private styleManager: GridStyleManager;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
|
const timezone = calendarConfig.getTimezone?.();
|
||||||
this.dateService = new DateService(timezone);
|
this.dateService = new DateService(timezone);
|
||||||
this.gridRenderer = new GridRenderer();
|
this.gridRenderer = new GridRenderer();
|
||||||
this.styleManager = new GridStyleManager();
|
this.styleManager = new GridStyleManager();
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,16 @@ export class DateService {
|
||||||
return format(date, 'yyyy-MM-dd');
|
return format(date, 'yyyy-MM-dd');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date as "Month Year" (e.g., "January 2025")
|
||||||
|
* @param date - Date to format
|
||||||
|
* @param locale - Locale for month name (default: 'en-US')
|
||||||
|
* @returns Formatted month and year
|
||||||
|
*/
|
||||||
|
public formatMonthYear(date: Date, locale: string = 'en-US'): string {
|
||||||
|
return date.toLocaleDateString(locale, { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date as ISO string (same as formatDate for compatibility)
|
* Format date as ISO string (same as formatDate for compatibility)
|
||||||
* @param date - Date to format
|
* @param date - Date to format
|
||||||
|
|
@ -414,6 +424,76 @@ export class DateService {
|
||||||
return isValid(date);
|
return isValid(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate date range (start must be before or equal to end)
|
||||||
|
* @param start - Start date
|
||||||
|
* @param end - End date
|
||||||
|
* @returns True if valid range
|
||||||
|
*/
|
||||||
|
public isValidRange(start: Date, end: Date): boolean {
|
||||||
|
if (!this.isValid(start) || !this.isValid(end)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return start.getTime() <= end.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is within reasonable bounds (1900-2100)
|
||||||
|
* @param date - Date to check
|
||||||
|
* @returns True if within bounds
|
||||||
|
*/
|
||||||
|
public isWithinBounds(date: Date): boolean {
|
||||||
|
if (!this.isValid(date)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const year = date.getFullYear();
|
||||||
|
return year >= 1900 && year <= 2100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate date with comprehensive checks
|
||||||
|
* @param date - Date to validate
|
||||||
|
* @param options - Validation options
|
||||||
|
* @returns Validation result with error message
|
||||||
|
*/
|
||||||
|
public validateDate(
|
||||||
|
date: Date,
|
||||||
|
options: {
|
||||||
|
requireFuture?: boolean;
|
||||||
|
requirePast?: boolean;
|
||||||
|
minDate?: Date;
|
||||||
|
maxDate?: Date;
|
||||||
|
} = {}
|
||||||
|
): { valid: boolean; error?: string } {
|
||||||
|
if (!this.isValid(date)) {
|
||||||
|
return { valid: false, error: 'Invalid date' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isWithinBounds(date)) {
|
||||||
|
return { valid: false, error: 'Date out of bounds (1900-2100)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (options.requireFuture && date <= now) {
|
||||||
|
return { valid: false, error: 'Date must be in the future' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.requirePast && date >= now) {
|
||||||
|
return { valid: false, error: 'Date must be in the past' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.minDate && date < options.minDate) {
|
||||||
|
return { valid: false, error: `Date must be after ${this.formatDate(options.minDate)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.maxDate && date > options.maxDate) {
|
||||||
|
return { valid: false, error: `Date must be before ${this.formatDate(options.maxDate)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if event spans multiple days
|
* Check if event spans multiple days
|
||||||
* @param start - Start date or ISO string
|
* @param start - Start date or ISO string
|
||||||
|
|
|
||||||
|
|
@ -222,12 +222,12 @@ export class PositionUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert time string to ISO datetime using DateService
|
* Convert time string to ISO datetime using DateService with timezone handling
|
||||||
*/
|
*/
|
||||||
public static timeStringToIso(timeString: string, date: Date = new Date()): string {
|
public static timeStringToIso(timeString: string, date: Date = new Date()): string {
|
||||||
const totalMinutes = PositionUtils.dateService.timeToMinutes(timeString);
|
const totalMinutes = PositionUtils.dateService.timeToMinutes(timeString);
|
||||||
const newDate = PositionUtils.dateService.createDateAtTime(date, totalMinutes);
|
const newDate = PositionUtils.dateService.createDateAtTime(date, totalMinutes);
|
||||||
return newDate.toISOString();
|
return PositionUtils.dateService.toUTC(newDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -59,7 +59,6 @@ swp-day-columns swp-event {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
margin-left: 0px;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue