Files
tessera-ctl/apps/web/src/components/dashboard/widgets/calendar-month.test.ts
T
schalli 4ddadc63ab feat(web): Kalender-Tag nach Kalender gruppieren, Kreise je Kalender berechnen (calendar-month)
- 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>
2026-09-16 14:34:49 +02:00

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([]);
});
});