4ddadc63ab
- groupDayBySource gruppiert Tagestermine nach sourceId (nicht Farbe), Reihenfolge = erstes Auftreten, Farbe = Farbe des ersten Termins der Gruppe - buildDayBadges baut daraus bis zu drei Plaketten-Kreise, ab dem vierten Kalender einen grauen Restkreis mit Summe - Tests 8/9 in calendar-month.test.ts (TDD: RED bestaetigt vor Implementierung) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
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>): 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([]);
|
|
});
|
|
});
|