/** * TDD Test Suite for EventStackManager * * This test suite follows Test-Driven Development principles: * 1. Write a failing test (RED) * 2. Write minimal code to make it pass (GREEN) * 3. Refactor if needed (REFACTOR) * * @see STACKING_CONCEPT.md for concept documentation */ import { describe, it, expect, beforeEach } from 'vitest'; import { EventStackManager, StackLink } from '../../src/managers/EventStackManager'; describe('EventStackManager - TDD Suite', () => { let manager: EventStackManager; beforeEach(() => { manager = new EventStackManager(); }); describe('Overlap Detection', () => { it('should detect overlap when event A starts before event B ends and event A ends after event B starts', () => { // RED - This test will fail initially const eventA = { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T11:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T12:00:00') }; // Expected: true (events overlap from 10:00 to 11:00) expect(manager.doEventsOverlap(eventA, eventB)).toBe(true); }); it('should return false when events do not overlap', () => { const eventA = { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T10:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T11:00:00'), end: new Date('2025-01-01T12:00:00') }; expect(manager.doEventsOverlap(eventA, eventB)).toBe(false); }); it('should detect overlap when one event completely contains another', () => { const eventA = { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T13:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; expect(manager.doEventsOverlap(eventA, eventB)).toBe(true); }); it('should return false when events touch but do not overlap', () => { const eventA = { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T10:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), // Exactly when A ends end: new Date('2025-01-01T11:00:00') }; expect(manager.doEventsOverlap(eventA, eventB)).toBe(false); }); }); describe('Find Overlapping Events', () => { it('should find all events that overlap with a given event', () => { const targetEvent = { id: 'target', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; const columnEvents = [ { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T10:30:00') // Overlaps }, { id: 'event-b', start: new Date('2025-01-01T12:00:00'), end: new Date('2025-01-01T13:00:00') // Does not overlap }, { id: 'event-c', start: new Date('2025-01-01T10:30:00'), end: new Date('2025-01-01T11:30:00') // Overlaps } ]; const overlapping = manager.findOverlappingEvents(targetEvent, columnEvents); expect(overlapping).toHaveLength(2); expect(overlapping.map(e => e.id)).toContain('event-a'); expect(overlapping.map(e => e.id)).toContain('event-c'); expect(overlapping.map(e => e.id)).not.toContain('event-b'); }); it('should return empty array when no events overlap', () => { const targetEvent = { id: 'target', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; const columnEvents = [ { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T09:30:00') }, { id: 'event-b', start: new Date('2025-01-01T12:00:00'), end: new Date('2025-01-01T13:00:00') } ]; const overlapping = manager.findOverlappingEvents(targetEvent, columnEvents); expect(overlapping).toHaveLength(0); }); }); describe('Create Stack Links', () => { it('should create stack links for overlapping events sorted by start time', () => { const events = [ { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T12:00:00') }, { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T11:00:00') }, { id: 'event-c', start: new Date('2025-01-01T11:00:00'), end: new Date('2025-01-01T13:00:00') } ]; const stackLinks = manager.createStackLinks(events); // Should be sorted by start time: event-a, event-b, event-c expect(stackLinks.size).toBe(3); const linkA = stackLinks.get('event-a'); expect(linkA).toEqual({ stackLevel: 0, next: 'event-b' // no prev }); const linkB = stackLinks.get('event-b'); expect(linkB).toEqual({ stackLevel: 1, prev: 'event-a', next: 'event-c' }); const linkC = stackLinks.get('event-c'); expect(linkC).toEqual({ stackLevel: 2, prev: 'event-b' // no next }); }); it('should return empty map for empty event array', () => { const stackLinks = manager.createStackLinks([]); expect(stackLinks.size).toBe(0); }); it('should create single stack link for single event', () => { const events = [ { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T10:00:00') } ]; const stackLinks = manager.createStackLinks(events); expect(stackLinks.size).toBe(1); const link = stackLinks.get('event-a'); expect(link).toEqual({ stackLevel: 0 // no prev, no next }); }); it('should handle events with same start time by sorting by end time', () => { const events = [ { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T12:00:00') // Longer event }, { id: 'event-a', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') // Shorter event (should come first) } ]; const stackLinks = manager.createStackLinks(events); // Shorter event should have lower stack level expect(stackLinks.get('event-a')?.stackLevel).toBe(0); expect(stackLinks.get('event-b')?.stackLevel).toBe(1); }); }); describe('Calculate Visual Styling', () => { it('should calculate marginLeft based on stack level', () => { const stackLevel = 0; expect(manager.calculateMarginLeft(stackLevel)).toBe(0); const stackLevel1 = 1; expect(manager.calculateMarginLeft(stackLevel1)).toBe(15); const stackLevel2 = 2; expect(manager.calculateMarginLeft(stackLevel2)).toBe(30); const stackLevel5 = 5; expect(manager.calculateMarginLeft(stackLevel5)).toBe(75); }); it('should calculate zIndex based on stack level', () => { const stackLevel = 0; expect(manager.calculateZIndex(stackLevel)).toBe(100); const stackLevel1 = 1; expect(manager.calculateZIndex(stackLevel1)).toBe(101); const stackLevel2 = 2; expect(manager.calculateZIndex(stackLevel2)).toBe(102); }); }); describe('Stack Link Serialization', () => { it('should serialize stack link to JSON string', () => { const stackLink: StackLink = { stackLevel: 1, prev: 'event-a', next: 'event-c' }; const serialized = manager.serializeStackLink(stackLink); expect(serialized).toBe('{"stackLevel":1,"prev":"event-a","next":"event-c"}'); }); it('should deserialize JSON string to stack link', () => { const json = '{"stackLevel":1,"prev":"event-a","next":"event-c"}'; const stackLink = manager.deserializeStackLink(json); expect(stackLink).toEqual({ stackLevel: 1, prev: 'event-a', next: 'event-c' }); }); it('should handle stack link without prev/next', () => { const stackLink: StackLink = { stackLevel: 0 }; const serialized = manager.serializeStackLink(stackLink); const deserialized = manager.deserializeStackLink(serialized); expect(deserialized).toEqual({ stackLevel: 0 }); }); it('should return null when deserializing invalid JSON', () => { const invalid = 'not-valid-json'; const result = manager.deserializeStackLink(invalid); expect(result).toBeNull(); }); }); describe('DOM Integration', () => { it('should apply stack link to DOM element', () => { const element = document.createElement('div'); element.dataset.eventId = 'event-a'; const stackLink: StackLink = { stackLevel: 1, prev: 'event-b', next: 'event-c' }; manager.applyStackLinkToElement(element, stackLink); expect(element.dataset.stackLink).toBe('{"stackLevel":1,"prev":"event-b","next":"event-c"}'); }); it('should read stack link from DOM element', () => { const element = document.createElement('div'); element.dataset.stackLink = '{"stackLevel":2,"prev":"event-a"}'; const stackLink = manager.getStackLinkFromElement(element); expect(stackLink).toEqual({ stackLevel: 2, prev: 'event-a' }); }); it('should return null when element has no stack link', () => { const element = document.createElement('div'); const stackLink = manager.getStackLinkFromElement(element); expect(stackLink).toBeNull(); }); it('should apply visual styling to element based on stack level', () => { const element = document.createElement('div'); manager.applyVisualStyling(element, 2); expect(element.style.marginLeft).toBe('30px'); expect(element.style.zIndex).toBe('102'); }); it('should clear stack link from element', () => { const element = document.createElement('div'); element.dataset.stackLink = '{"stackLevel":1}'; manager.clearStackLinkFromElement(element); expect(element.dataset.stackLink).toBeUndefined(); }); it('should clear visual styling from element', () => { const element = document.createElement('div'); element.style.marginLeft = '30px'; element.style.zIndex = '102'; manager.clearVisualStyling(element); expect(element.style.marginLeft).toBe(''); expect(element.style.zIndex).toBe(''); }); }); describe('Edge Cases', () => { it('should optimize stack levels when events do not overlap each other but both overlap a parent event', () => { // Visual representation: // Event A: 09:00 ════════════════════════════ 14:00 // Event B: 10:00 ═════ 12:00 // Event C: 12:30 ═══ 13:00 // // Expected stacking: // Event A: stackLevel 0 (base) // Event B: stackLevel 1 (conflicts with A) // Event C: stackLevel 1 (conflicts with A, but NOT with B - can share same level!) const eventA = { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T14:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T12:00:00') }; const eventC = { id: 'event-c', start: new Date('2025-01-01T12:30:00'), end: new Date('2025-01-01T13:00:00') }; const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC]); expect(stackLinks.size).toBe(3); // Event A is the base (contains both B and C) expect(stackLinks.get('event-a')?.stackLevel).toBe(0); // Event B and C should both be at stackLevel 1 (they don't overlap each other) expect(stackLinks.get('event-b')?.stackLevel).toBe(1); expect(stackLinks.get('event-c')?.stackLevel).toBe(1); // Verify they are NOT linked to each other (no prev/next between B and C) expect(stackLinks.get('event-b')?.next).toBeUndefined(); expect(stackLinks.get('event-c')?.prev).toBeUndefined(); }); it('should create multiple parallel tracks when events at same level do not overlap', () => { // Complex scenario with multiple parallel tracks: // Event A: 09:00 ════════════════════════════════════ 15:00 // Event B: 10:00 ═══ 11:00 // Event C: 11:30 ═══ 12:30 // Event D: 13:00 ═══ 14:00 // // Expected: // - A at level 0 (base) // - B, C, D all at level 1 (they don't overlap each other, only with A) const eventA = { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T15:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; const eventC = { id: 'event-c', start: new Date('2025-01-01T11:30:00'), end: new Date('2025-01-01T12:30:00') }; const eventD = { id: 'event-d', start: new Date('2025-01-01T13:00:00'), end: new Date('2025-01-01T14:00:00') }; const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC, eventD]); expect(stackLinks.size).toBe(4); expect(stackLinks.get('event-a')?.stackLevel).toBe(0); expect(stackLinks.get('event-b')?.stackLevel).toBe(1); expect(stackLinks.get('event-c')?.stackLevel).toBe(1); expect(stackLinks.get('event-d')?.stackLevel).toBe(1); }); it('should handle nested overlaps with optimal stacking', () => { // Scenario: // Event A: 09:00 ════════════════════════════════════ 15:00 // Event B: 10:00 ════════════════════ 13:00 // Event C: 11:00 ═══ 12:00 // Event D: 12:30 ═══ 13:30 // // Expected: // - A at level 0 (base, contains all) // - B at level 1 (overlaps with A) // - C at level 2 (overlaps with A and B) // - D at level 2 (overlaps with A and B, but NOT with C - can share level with C) const eventA = { id: 'event-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T15:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T13:00:00') }; const eventC = { id: 'event-c', start: new Date('2025-01-01T11:00:00'), end: new Date('2025-01-01T12:00:00') }; const eventD = { id: 'event-d', start: new Date('2025-01-01T12:30:00'), end: new Date('2025-01-01T13:30:00') }; const stackLinks = manager.createOptimizedStackLinks([eventA, eventB, eventC, eventD]); expect(stackLinks.size).toBe(4); expect(stackLinks.get('event-a')?.stackLevel).toBe(0); expect(stackLinks.get('event-b')?.stackLevel).toBe(1); expect(stackLinks.get('event-c')?.stackLevel).toBe(2); expect(stackLinks.get('event-d')?.stackLevel).toBe(2); // Can share level with C }); it('should handle events with identical start and end times', () => { const eventA = { id: 'event-a', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; expect(manager.doEventsOverlap(eventA, eventB)).toBe(true); const stackLinks = manager.createStackLinks([eventA, eventB]); expect(stackLinks.size).toBe(2); }); it('should handle events with zero duration', () => { const eventA = { id: 'event-a', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T10:00:00') // Zero duration }; const eventB = { id: 'event-b', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; // Zero-duration event should not overlap expect(manager.doEventsOverlap(eventA, eventB)).toBe(false); }); it('should handle large number of overlapping events', () => { const events = Array.from({ length: 100 }, (_, i) => ({ id: `event-${i}`, start: new Date('2025-01-01T09:00:00'), end: new Date(`2025-01-01T${10 + i}:00:00`) })); const stackLinks = manager.createStackLinks(events); expect(stackLinks.size).toBe(100); expect(stackLinks.get('event-0')?.stackLevel).toBe(0); expect(stackLinks.get('event-99')?.stackLevel).toBe(99); }); }); describe('Integration Tests', () => { it('should create complete stack for new event with overlapping events', () => { // Scenario: Adding new event that overlaps with existing events const newEvent = { id: 'new-event', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; const existingEvents = [ { id: 'existing-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T10:30:00') }, { id: 'existing-b', start: new Date('2025-01-01T10:30:00'), end: new Date('2025-01-01T12:00:00') } ]; // Find overlapping const overlapping = manager.findOverlappingEvents(newEvent, existingEvents); // Create stack links for all events const allEvents = [...overlapping, newEvent]; const stackLinks = manager.createStackLinks(allEvents); // Verify complete stack expect(stackLinks.size).toBe(3); expect(stackLinks.get('existing-a')?.stackLevel).toBe(0); expect(stackLinks.get('new-event')?.stackLevel).toBe(1); expect(stackLinks.get('existing-b')?.stackLevel).toBe(2); }); it('should handle complete workflow: detect, create, apply to DOM', () => { const newEvent = { id: 'new-event', start: new Date('2025-01-01T10:00:00'), end: new Date('2025-01-01T11:00:00') }; const existingEvents = [ { id: 'existing-a', start: new Date('2025-01-01T09:00:00'), end: new Date('2025-01-01T10:30:00') } ]; // Step 1: Find overlapping const overlapping = manager.findOverlappingEvents(newEvent, existingEvents); expect(overlapping).toHaveLength(1); // Step 2: Create stack links const allEvents = [...overlapping, newEvent]; const stackLinks = manager.createStackLinks(allEvents); expect(stackLinks.size).toBe(2); // Step 3: Apply to DOM const elementA = document.createElement('div'); elementA.dataset.eventId = 'existing-a'; const elementNew = document.createElement('div'); elementNew.dataset.eventId = 'new-event'; manager.applyStackLinkToElement(elementA, stackLinks.get('existing-a')!); manager.applyStackLinkToElement(elementNew, stackLinks.get('new-event')!); manager.applyVisualStyling(elementA, stackLinks.get('existing-a')!.stackLevel); manager.applyVisualStyling(elementNew, stackLinks.get('new-event')!.stackLevel); // Verify DOM state expect(elementA.dataset.stackLink).toContain('"stackLevel":0'); expect(elementA.style.marginLeft).toBe('0px'); expect(elementNew.dataset.stackLink).toContain('"stackLevel":1'); expect(elementNew.style.marginLeft).toBe('15px'); }); }); });