Stacking and Sharecolumn WIP
This commit is contained in:
parent
c788a1695e
commit
6b8c5d4673
7 changed files with 763 additions and 51 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue