Stacking and Sharecolumn WIP

This commit is contained in:
Janus C. H. Knudsen 2025-10-06 17:05:18 +02:00
parent c788a1695e
commit 6b8c5d4673
7 changed files with 763 additions and 51 deletions

View file

@ -2,11 +2,12 @@
* EventStackManager - Flexbox + Nested Stacking Tests
*
* Tests for the 3-phase algorithm:
* Phase 1: Group events by start time proximity (±15 min threshold)
* Phase 1: Group events by start time proximity (±N min threshold - configurable)
* Phase 2: Decide container type (GRID vs STACKING)
* Phase 3: Handle late arrivals (nested stacking)
*
* Based on scenarios from stacking-visualization.html
* Tests are dynamic and work with any gridStartThresholdMinutes value from config.
*
* @see STACKING_CONCEPT.md for concept documentation
* @see stacking-visualization.html for visual examples
@ -14,12 +15,17 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { EventStackManager } from '../../src/managers/EventStackManager';
import { EventLayoutCoordinator } from '../../src/managers/EventLayoutCoordinator';
import { calendarConfig } from '../../src/core/CalendarConfig';
describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', () => {
let manager: EventStackManager;
let thresholdMinutes: number;
beforeEach(() => {
manager = new EventStackManager();
// Get threshold from config - tests should work with any value
thresholdMinutes = calendarConfig.getGridSettings().gridStartThresholdMinutes;
});
// ============================================
@ -27,7 +33,7 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
// ============================================
describe('Phase 1: Start Time Grouping', () => {
it('should group events starting within ±15 minutes together', () => {
it('should group events starting within threshold minutes together', () => {
const events = [
{
id: 'event-a',
@ -53,28 +59,33 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
expect(groups[0].events.map(e => e.id)).toEqual(['event-a', 'event-b', 'event-c']);
});
it('should NOT group events starting more than 15 minutes apart', () => {
it('should NOT group events with no time conflicts', () => {
// Event C starts (threshold + 1) minutes AFTER A ends (no conflict)
const eventAEnd = new Date('2025-01-01T12:00:00');
const eventCStart = new Date(eventAEnd.getTime());
eventCStart.setMinutes(eventCStart.getMinutes() + thresholdMinutes + 1);
const events = [
{
id: 'event-a',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:30:00')
end: eventAEnd // Ends at 12:00
},
{
id: 'event-b',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00')
end: new Date('2025-01-01T11:30:00')
},
{
id: 'event-c',
start: new Date('2025-01-01T11:30:00'), // 30 min after A (exceeds threshold)
end: new Date('2025-01-01T11:45:00')
start: eventCStart, // Starts at 12:00 + (threshold + 1) min
end: new Date('2025-01-01T13:00:00')
}
];
const groups = manager.groupEventsByStartTime(events);
// Event C should be in separate group
// Event C should be in separate group (no conflict with A or B)
expect(groups).toHaveLength(2);
const firstGroup = groups.find(g => g.events.some(e => e.id === 'event-a'));
@ -112,7 +123,11 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
expect(groups[0].events.map(e => e.id)).toEqual(['event-a', 'event-b', 'event-c']);
});
it('should handle edge case: events exactly 15 minutes apart (should be grouped)', () => {
it('should handle edge case: events exactly at threshold minutes apart (should be grouped)', () => {
// Event B starts exactly threshold minutes after A
const eventBStart = new Date('2025-01-01T11:00:00');
eventBStart.setMinutes(eventBStart.getMinutes() + thresholdMinutes);
const events = [
{
id: 'event-a',
@ -121,8 +136,8 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
},
{
id: 'event-b',
start: new Date('2025-01-01T11:15:00'), // Exactly 15 min
end: new Date('2025-01-01T12:00:00')
start: eventBStart, // Exactly threshold min
end: new Date('2025-01-01T12:30:00')
}
];
@ -132,17 +147,22 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
expect(groups[0].events).toHaveLength(2);
});
it('should handle edge case: events exactly 16 minutes apart (should NOT be grouped)', () => {
it('should handle edge case: events at (threshold + 1) minutes apart with no overlap (should NOT be grouped)', () => {
// Event B starts (threshold + 1) minutes AFTER A ends (no conflict)
const eventAEnd = new Date('2025-01-01T12:00:00');
const eventBStart = new Date(eventAEnd.getTime());
eventBStart.setMinutes(eventBStart.getMinutes() + thresholdMinutes + 1);
const events = [
{
id: 'event-a',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00')
end: eventAEnd
},
{
id: 'event-b',
start: new Date('2025-01-01T11:16:00'), // 16 min > 15 min threshold
end: new Date('2025-01-01T12:00:00')
start: eventBStart, // Starts after A ends + (threshold + 1) min
end: new Date('2025-01-01T13:00:00')
}
];
@ -796,7 +816,7 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
}
];
// Test stack links
// Test stack links - these should be consistent regardless of grouping
const stackLinks = manager.createOptimizedStackLinks(events);
expect(stackLinks.get('151')?.stackLevel).toBe(0);
@ -805,20 +825,25 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
expect(stackLinks.get('1513')?.stackLevel).toBe(3);
expect(stackLinks.get('1514')?.stackLevel).toBe(4); // Must be above 1513 (they overlap)
// Test grouping
// Test grouping - behavior depends on threshold
const groups = manager.groupEventsByStartTime(events);
// Should have 4 groups: {151}, {1511}, {1512}, {1513, 1514}
expect(groups).toHaveLength(4);
// Events are spaced 30 min apart, so:
// - If threshold >= 30: all 5 events group together
// - If threshold < 30: events group separately
const group1513_1514 = groups.find(g => g.events.some(e => e.id === '1513'));
expect(group1513_1514).toBeDefined();
expect(group1513_1514?.events).toHaveLength(2);
expect(group1513_1514?.events.map(e => e.id).sort()).toEqual(['1513', '1514']);
// Find group containing 1513
const group1513 = groups.find(g => g.events.some(e => e.id === '1513'));
expect(group1513).toBeDefined();
// Test container type - should be GRID
const containerType = manager.decideContainerType(group1513_1514!);
expect(containerType).toBe('GRID');
// 1513 and 1514 start at same time, so should always be in same group
expect(group1513?.events.some(e => e.id === '1514')).toBe(true);
// If group has multiple events, container type should be GRID
if (group1513!.events.length > 1) {
const containerType = manager.decideContainerType(group1513!);
expect(containerType).toBe('GRID');
}
});
it('Debug: Events 144, 145, 146 overlap detection', () => {
@ -954,6 +979,190 @@ describe('EventStackManager - Flexbox & Nested Stacking (3-Phase Algorithm)', ()
// (they're side-by-side, not stacked)
});
it('Scenario 8: Edge case - Events exactly 15 min apart WITH overlap', () => {
// Event A: 11:00 - 12:00
// Event B: 11:15 - 12:30
// Difference: 15 min (exactly at threshold)
// Overlap: YES (11:15 - 12:00)
const events = [
{
id: 'event-a',
start: new Date('2025-01-01T11:00:00'),
end: new Date('2025-01-01T12:00:00')
},
{
id: 'event-b',
start: new Date('2025-01-01T11:15:00'),
end: new Date('2025-01-01T12:30:00')
}
];
// Step 1: Verify they're grouped together (15 min ≤ threshold)
const groups = manager.groupEventsByStartTime(events);
expect(groups).toHaveLength(1); // Same group
expect(groups[0].events).toHaveLength(2); // Both events in group
// Step 2: Verify they DO overlap
const overlap = manager.doEventsOverlap(events[0], events[1]);
expect(overlap).toBe(true);
// Step 3: CRITICAL: Despite overlapping, should use GRID (visual priority = simultaneity)
const containerType = manager.decideContainerType(groups[0]);
expect(containerType).toBe('GRID');
// Step 4: Verify stack levels for understanding (even though not used for GRID rendering)
const stackLinks = manager.createOptimizedStackLinks(events);
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
expect(stackLinks.get('event-b')?.stackLevel).toBe(1); // Would be stacked if not in GRID
});
it('Scenario 9: End-to-Start conflicts - Events share grid despite start times', () => {
// Event A: 09:00 - 10:00
// Event B: 09:30 - 10:30 (starts 30 min before A ends → conflicts with A)
// Event C: 10:15 - 12:00 (starts 15 min before B ends → conflicts with B)
//
// Key Rule: Events share columns (GRID) when they conflict within threshold
// Conflict = Event starts within ±threshold minutes of another event's end time
//
// With threshold = 30 min:
// - A-B conflict: B starts 30 min before A ends (≤ 30 min) → grouped
// - B-C conflict: C starts 15 min before B ends (≤ 30 min) → grouped
// - Therefore: A, B, C all in same GRID group
// - A and C don't overlap → share same grid column
// - Result: 2-column grid (Column 1: A+C, Column 2: B)
const events = [
{
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00')
},
{
id: 'event-b',
start: new Date('2025-01-01T09:30:00'),
end: new Date('2025-01-01T10:30:00')
},
{
id: 'event-c',
start: new Date('2025-01-01T10:15:00'),
end: new Date('2025-01-01T12:00:00')
}
];
// Step 1: Verify overlaps
const overlapAB = manager.doEventsOverlap(events[0], events[1]);
const overlapBC = manager.doEventsOverlap(events[1], events[2]);
const overlapAC = manager.doEventsOverlap(events[0], events[2]);
expect(overlapAB).toBe(true); // A: 09:00-10:00, B: 09:30-10:30 → overlap 09:30-10:00
expect(overlapBC).toBe(true); // B: 09:30-10:30, C: 10:15-12:00 → overlap 10:15-10:30
expect(overlapAC).toBe(false); // A: 09:00-10:00, C: 10:15-12:00 → NO overlap
// Step 2: Grouping based on end-to-start conflicts
const groups = manager.groupEventsByStartTime(events);
if (thresholdMinutes >= 30) {
// All 3 events should be in ONE group (due to chained end-to-start conflicts)
expect(groups).toHaveLength(1);
expect(groups[0].events).toHaveLength(3);
expect(groups[0].events.map(e => e.id).sort()).toEqual(['event-a', 'event-b', 'event-c']);
// Should use GRID container type
const containerType = manager.decideContainerType(groups[0]);
expect(containerType).toBe('GRID');
} else if (thresholdMinutes >= 15) {
// With 15 ≤ threshold < 30:
// - A-B: 30 min conflict > 15 → NOT grouped
// - B-C: 15 min conflict ≤ 15 → grouped
// Result: 2 groups
expect(groups.length).toBeGreaterThanOrEqual(2);
}
// Step 3: Stack levels (for understanding, even though rendered as GRID)
const stackLinks = manager.createOptimizedStackLinks(events);
expect(stackLinks.get('event-a')?.stackLevel).toBe(0);
expect(stackLinks.get('event-b')?.stackLevel).toBe(1); // overlaps A
expect(stackLinks.get('event-c')?.stackLevel).toBe(2); // overlaps B (must be above B's level)
// Note: Even though C has stackLevel 2, it will share a grid column with A
// because they don't overlap. Column allocation is different from stack levels.
});
it('Scenario 9: Column allocation - A and C share column, B in separate column', () => {
// This test verifies the column allocation logic for Scenario 9
// Event A: 09:00-10:00, Event B: 09:30-10:30, Event C: 10:15-12:00
//
// Expected columns:
// - Column 1: [A, C] (they don't overlap)
// - Column 2: [B] (overlaps both A and C)
const events = [
{
id: 'event-a',
start: new Date('2025-01-01T09:00:00'),
end: new Date('2025-01-01T10:00:00'),
title: 'Event A',
type: 'work' as const,
allDay: false,
syncStatus: 'synced' as const
},
{
id: 'event-b',
start: new Date('2025-01-01T09:30:00'),
end: new Date('2025-01-01T10:30:00'),
title: 'Event B',
type: 'work' as const,
allDay: false,
syncStatus: 'synced' as const
},
{
id: 'event-c',
start: new Date('2025-01-01T10:15:00'),
end: new Date('2025-01-01T12:00:00'),
title: 'Event C',
type: 'work' as const,
allDay: false,
syncStatus: 'synced' as const
}
];
// Use EventLayoutCoordinator to test column allocation
const coordinator = new EventLayoutCoordinator();
if (thresholdMinutes >= 30) {
// Calculate layout
const layout = coordinator.calculateColumnLayout(events);
// Should have 1 grid group (all events grouped together)
expect(layout.gridGroups).toHaveLength(1);
const gridGroup = layout.gridGroups[0];
// Should have 2 columns
expect(gridGroup.columns).toHaveLength(2);
// Column 1 should contain A and C (they don't overlap)
const column1 = gridGroup.columns.find(col =>
col.some(e => e.id === 'event-a') && col.some(e => e.id === 'event-c')
);
expect(column1).toBeDefined();
expect(column1).toHaveLength(2);
expect(column1?.map(e => e.id).sort()).toEqual(['event-a', 'event-c']);
// Column 2 should contain only B
const column2 = gridGroup.columns.find(col =>
col.some(e => e.id === 'event-b')
);
expect(column2).toBeDefined();
expect(column2).toHaveLength(1);
expect(column2?.[0].id).toBe('event-b');
// No stacked events (all in grid)
expect(layout.stackedEvents).toHaveLength(0);
}
});
it.skip('Scenario 6: Grid + D nested in B column (NOT IMPLEMENTED - requires Phase 3)', () => {
// Event A: 10:00 - 13:00
// Event B: 11:00 - 12:30 (flexbox column 1)