import { describe, expect, it } from 'vitest'; import type { CalendarEvent } from '@/lib/calendar-api'; import { addDays, buildCalendarDays, buildDayBadges, computeFetchWindow, dateKey, formatEventDate, formatEventTime, formatMonthLabel, gridStartFor, groupDayBySource, groupEventsByDate, resolveCalendarConfig, selectUpcomingEvents, startOfLocalDay, } from './calendar-month'; /** Kleine Fabrik fuer Testtermine (Muster aus dem Plan). */ function ev(id: string, start: Date, end: Date, extra?: Partial): CalendarEvent { return { id, sourceId: 's1', title: id, start: start.toISOString(), end: end.toISOString(), allDay: false, ...extra, }; } describe('calendar-month', () => { it('Test 1: buildCalendarDays liefert 42 Zellen ab Montag mit korrektem Monatsflag und heutigem Tag', () => { const days = buildCalendarDays(new Date(2026, 6, 1), new Map(), new Date(2026, 6, 15)); expect(days).toHaveLength(42); expect(days[0].key).toBe('2026-06-29'); expect(days[0].inCurrentMonth).toBe(false); expect(days[2].key).toBe('2026-07-01'); expect(days[2].inCurrentMonth).toBe(true); expect(days[41].key).toBe('2026-08-09'); const todayCells = days.filter((d) => d.isToday); expect(todayCells).toHaveLength(1); expect(todayCells[0].key).toBe('2026-07-15'); const outside = days.filter((d) => !d.inCurrentMonth); expect(outside).toHaveLength(11); }); it('Test 2: groupEventsByDate gruppiert nach lokalem Startdatum, buildCalendarDays uebernimmt die Zaehlung', () => { const events = [ ev('e1', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), ev('e2', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)), ev('e3', new Date(2026, 6, 21, 10, 0), new Date(2026, 6, 21, 11, 0)), ]; const grouped = groupEventsByDate(events); expect(grouped.size).toBe(2); expect(grouped.get('2026-07-20')).toHaveLength(2); const days = buildCalendarDays(new Date(2026, 6, 1), grouped); const day20 = days.find((d) => d.key === '2026-07-20'); expect(day20?.events.length).toBe(2); }); it('Test 2b: groupEventsByDate sortiert jede Tagesgruppe nach Start aufsteigend', () => { const events = [ ev('e2', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)), ev('e1', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), ]; const grouped = groupEventsByDate(events); expect(grouped.get('2026-07-20')?.map((e) => e.id)).toEqual(['e1', 'e2']); }); it('Test 3: resolveCalendarConfig klemmt ungueltige Werte auf die Vorgaben/Grenzen zurueck', () => { expect(resolveCalendarConfig({})).toEqual({ showMonth: true, maxEvents: 3, lookaheadDays: 30 }); expect(resolveCalendarConfig({ showMonth: false }).showMonth).toBe(false); expect(resolveCalendarConfig({ showMonth: 'nein' }).showMonth).toBe(true); expect(resolveCalendarConfig({ maxEvents: 99 }).maxEvents).toBe(10); expect(resolveCalendarConfig({ maxEvents: -1 }).maxEvents).toBe(0); expect(resolveCalendarConfig({ maxEvents: 4.7 }).maxEvents).toBe(4); expect(resolveCalendarConfig({ maxEvents: '5' }).maxEvents).toBe(3); expect(resolveCalendarConfig({ lookaheadDays: 45 }).lookaheadDays).toBe(30); expect(resolveCalendarConfig({ lookaheadDays: 90 }).lookaheadDays).toBe(90); }); it('Test 4: computeFetchWindow deckt Raster und Vorschau-Zeitraum als Tagesgrenzen ab', () => { const w1 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 6, 15, 10, 30)); expect(w1.from.getTime()).toBe(new Date(2026, 5, 29).getTime()); expect(w1.to.getTime()).toBe(new Date(2026, 7, 14).getTime()); const w2 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 4, 1, 8, 0)); expect(w2.from.getTime()).toBe(new Date(2026, 4, 1).getTime()); expect(w2.to.getTime()).toBe(new Date(2026, 7, 10).getTime()); const w3 = computeFetchWindow(new Date(2026, 6, 1), 90, new Date(2026, 6, 15, 10, 30)); expect(w3.to.getTime()).toBe(new Date(2026, 9, 13).getTime()); for (const w of [w1, w2, w3]) { for (const d of [w.from, w.to]) { expect(d.getHours()).toBe(0); expect(d.getMinutes()).toBe(0); expect(d.getSeconds()).toBe(0); expect(d.getMilliseconds()).toBe(0); } } }); it('Test 5: selectUpcomingEvents filtert Vergangenheit/Horizont, sortiert nach Start und kuerzt auf maxEvents', () => { const now = new Date(2026, 6, 15, 10, 0); const events = [ ev('gestern', new Date(2026, 6, 14, 11, 0), new Date(2026, 6, 14, 12, 0)), ev('läuft', new Date(2026, 6, 15, 9, 0), new Date(2026, 6, 15, 11, 0)), ev('heute 15', new Date(2026, 6, 15, 15, 0), new Date(2026, 6, 15, 16, 0)), ev('in 5 Tagen', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), ev('in 10 Tagen', new Date(2026, 6, 25, 9, 0), new Date(2026, 6, 25, 10, 0)), ]; const result = selectUpcomingEvents(events, 7, 2, now); expect(result.map((e) => e.title)).toEqual(['läuft', 'heute 15']); expect(selectUpcomingEvents(events, 7, 0, now)).toEqual([]); }); it('Test 6: formatEventDate/formatEventTime/formatMonthLabel formatieren de-DE', () => { const timed = ev('t1', new Date(2026, 6, 20, 9, 5), new Date(2026, 6, 20, 10, 0)); const timedText = formatEventDate(timed); expect(timedText).toMatch(/20\.07\./); expect(timedText).toMatch(/09:05/); const allDay = ev('t2', new Date(2026, 6, 20, 0, 0), new Date(2026, 6, 20, 23, 59), { allDay: true }); const allDayText = formatEventDate(allDay); expect(allDayText).toMatch(/20\.07\./); expect(allDayText).not.toMatch(/\d{2}:\d{2}/); expect(formatMonthLabel(new Date(2026, 6, 1))).toBe('Juli 2026'); expect(formatEventTime(new Date(2026, 6, 20, 9, 5).toISOString())).toBe('09:05'); }); it('Test 7: gridStartFor findet den Montag am oder vor dem 1. des Monats', () => { expect(gridStartFor(new Date(2026, 7, 1)).getTime()).toBe(new Date(2026, 6, 27).getTime()); expect(gridStartFor(new Date(2026, 5, 1)).getTime()).toBe(new Date(2026, 5, 1).getTime()); }); it('Zusatz: dateKey/startOfLocalDay/addDays arbeiten lokal ohne UTC-Versatz', () => { expect(dateKey(new Date(2026, 6, 5))).toBe('2026-07-05'); const start = startOfLocalDay(new Date(2026, 6, 5, 14, 30, 15)); expect(start.getHours()).toBe(0); expect(addDays(new Date(2026, 6, 30), 2).getDate()).toBe(1); }); it('Test 8: groupDayBySource gruppiert nach sourceId in Reihenfolge des ersten Auftretens', () => { const day = new Date(2026, 6, 20, 9, 0); const hourLater = new Date(2026, 6, 20, 10, 0); // (a) drei Termine, alle s1 mit derselben Farbe const sameSource = [ ev('e1', day, hourLater, { sourceId: 's1', color: '#c44040' }), ev('e2', day, hourLater, { sourceId: 's1', color: '#c44040' }), ev('e3', day, hourLater, { sourceId: 's1', color: '#c44040' }), ]; expect(groupDayBySource(sameSource)).toEqual([{ sourceId: 's1', color: '#c44040', count: 3 }]); // (b) Reihenfolge s2, s1, s2 wird NICHT umsortiert const mixedOrder = [ ev('e1', day, hourLater, { sourceId: 's2' }), ev('e2', day, hourLater, { sourceId: 's1' }), ev('e3', day, hourLater, { sourceId: 's2' }), ]; const groupedMixed = groupDayBySource(mixedOrder); expect(groupedMixed.map((g) => g.sourceId)).toEqual(['s2', 's1']); expect(groupedMixed[0].count).toBe(2); expect(groupedMixed[1].count).toBe(1); // (c) Termine ohne color -> color der Gruppe ist undefined const noColor = [ev('e1', day, hourLater, { sourceId: 's1', color: undefined })]; expect(groupDayBySource(noColor)[0].color).toBeUndefined(); // (d) zwei Kalender mit derselben Farbe bleiben zwei Gruppen (Gruppierung nach sourceId) const sameColorDifferentSource = [ ev('e1', day, hourLater, { sourceId: 's1', color: '#123456' }), ev('e2', day, hourLater, { sourceId: 's2', color: '#123456' }), ]; expect(groupDayBySource(sameColorDifferentSource)).toHaveLength(2); // (e) Farbe der Gruppe ist die Farbe des ERSTEN Termins der Gruppe const laterColorChange = [ ev('e1', day, hourLater, { sourceId: 's1', color: '#111111' }), ev('e2', day, hourLater, { sourceId: 's1', color: '#222222' }), ]; expect(groupDayBySource(laterColorChange)).toEqual([ { sourceId: 's1', color: '#111111', count: 2 }, ]); // (f) leeres Array expect(groupDayBySource([])).toEqual([]); }); it('Test 9: buildDayBadges liefert je Kalender einen Eintrag, ab dem vierten einen grauen Restkreis', () => { const day = new Date(2026, 6, 20); const single = [ev('e1', day, day, { sourceId: 's1', color: '#111111' })]; expect(buildDayBadges(single)).toEqual([ { key: 's1', color: '#111111', count: 1, rest: false }, ]); const two = [ ev('e1', day, day, { sourceId: 's1', color: '#111111' }), ev('e2', day, day, { sourceId: 's2', color: '#222222' }), ]; expect(buildDayBadges(two)).toEqual([ { key: 's1', color: '#111111', count: 1, rest: false }, { key: 's2', color: '#222222', count: 1, rest: false }, ]); const three = [ ev('e1', day, day, { sourceId: 's1', color: '#111111' }), ev('e2', day, day, { sourceId: 's2', color: '#222222' }), ev('e3', day, day, { sourceId: 's3', color: '#333333' }), ]; const threeResult = buildDayBadges(three); expect(threeResult).toHaveLength(3); expect(threeResult.every((b) => b.rest === false)).toBe(true); const four = [ ev('e1', day, day, { sourceId: 's1', color: '#111111' }), ev('e2a', day, day, { sourceId: 's2', color: '#222222' }), ev('e2b', day, day, { sourceId: 's2', color: '#222222' }), ev('e3', day, day, { sourceId: 's3', color: '#333333' }), ev('e4a', day, day, { sourceId: 's4', color: '#444444' }), ev('e4b', day, day, { sourceId: 's4', color: '#444444' }), ev('e4c', day, day, { sourceId: 's4', color: '#444444' }), ]; expect(buildDayBadges(four)).toEqual([ { key: 's1', color: '#111111', count: 1, rest: false }, { key: 's2', color: '#222222', count: 2, rest: false }, { key: '__rest__', color: undefined, count: 4, rest: true }, ]); expect(buildDayBadges(four, 2)).toEqual([ { key: 's1', color: '#111111', count: 1, rest: false }, { key: '__rest__', color: undefined, count: 6, rest: true }, ]); expect(buildDayBadges([])).toEqual([]); }); });