Files
planer/src/lib/kalender/belegung.test.ts
T
Florian WildeandClaude Opus 5 6fda2a579e feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
Reine Logik ohne fetch: die Antworten beider Anbieter werden mit zod
geprüft und in eine gemeinsame Form gebracht. Geprüft wird gegen
anonymisierte Beispielantworten in test/fixtures — 61 Tests.

Zwei Stellen, an denen bewusst abgebrochen statt geraten wird:
- Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir
  fragen mit Prefer: outlook.timezone="UTC" an; kommt etwas anderes
  zurück, wäre jede Umrechnung geraten und würde Termine um Stunden
  verschieben.
- Ein unbekanntes showAs wird UNBEKANNT und zählt damit als belegt. Lieber
  eine Stunde zu viel als eine Doppelbuchung.

Zur Belegung: Ganztägige Termine zählen nicht — ein Urlaubstag oder
Geburtstag im Kalender blockiert sonst den ganzen Tag, obwohl er keine
Arbeitszeit kostet. Ebenso wenig zählen ausdrücklich als frei markierte
Termine. Überlappende Termine werden zusammengelegt, damit eine
Doppelbuchung nicht doppelt zählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00

338 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
arbeitsfensterAmTag,
belegteMinuten,
belegtZeit,
freieFenster,
kapazitaetAmTag,
tagesBelegung,
termineAmTag,
ueberschneidungMinuten,
type BelegenderTermin,
type BereichKapazitaet,
} from './belegung'
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
const dienstag = new Date('2026-03-17T09:00:00Z')
const bereich: BereichKapazitaet = {
kapazitaetMo: 360,
kapazitaetDi: 240,
kapazitaetMi: 360,
kapazitaetDo: 360,
kapazitaetFr: 120,
kapazitaetSa: 0,
kapazitaetSo: 0,
// 08:00 bis 17:00 Berliner Zeit.
arbeitsfensterVon: 480,
arbeitsfensterBis: 1020,
}
function termin(
vonUtc: string,
bisUtc: string,
teil: Partial<BelegenderTermin> = {},
): BelegenderTermin {
return {
beginn: new Date(vonUtc),
ende: new Date(bisUtc),
ganztaegig: false,
zeigtAls: 'GEBUCHT',
...teil,
}
}
describe('belegtZeit', () => {
it('zählt einen gewöhnlichen Termin', () => {
expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true)
})
it('zählt ganztägige Termine nicht', () => {
// Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster.
expect(
belegtZeit(
termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }),
),
).toBe(false)
})
it('zählt als frei markierte Termine nicht', () => {
expect(
belegtZeit(
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }),
),
).toBe(false)
})
it('zählt Vorbehalt und Abwesenheit sehr wohl', () => {
for (const zeigtAls of [
'MIT_VORBEHALT',
'ABWESEND',
'WOANDERS',
'UNBEKANNT',
] as const) {
expect(
belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })),
).toBe(true)
}
})
})
describe('ueberschneidungMinuten', () => {
const fenster = {
von: new Date('2026-03-17T08:00:00Z'),
bis: new Date('2026-03-17T12:00:00Z'),
}
it('rechnet eine vollständige Überschneidung', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') },
fenster,
),
).toBe(60)
})
it('schneidet an den Rändern ab', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') },
fenster,
),
).toBe(60)
})
it('ist ohne Überschneidung null', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') },
fenster,
),
).toBe(0)
})
it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') },
fenster,
),
).toBe(0)
})
})
describe('arbeitsfensterAmTag', () => {
it('legt das Fenster in Berliner Ortszeit', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
// 08:00 Berlin = 07:00 UTC in der Winterzeit.
expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
})
it('verschiebt sich mit der Sommerzeit', () => {
// Ab dem 29. März 2026 gilt in Berlin UTC+2.
const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020)
expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z')
expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z')
})
})
describe('kapazitaetAmTag', () => {
it('nimmt den richtigen Wochentag', () => {
expect(kapazitaetAmTag(bereich, dienstag)).toBe(240)
// Montag, 16. März.
expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360)
// Sonntag, 22. März.
expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0)
})
})
describe('belegteMinuten', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
it('ist ohne Termine null', () => {
expect(belegteMinuten([], fenster)).toBe(0)
})
it('summiert getrennte Termine', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
],
fenster,
),
).toBe(120)
})
it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
],
fenster,
),
).toBe(180)
})
it('legt lückenlos aneinandergrenzende Termine zusammen', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
],
fenster,
),
).toBe(120)
})
it('zählt einen vollständig enthaltenen Termin nur einmal', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
],
fenster,
),
).toBe(240)
})
it('schneidet an den Rändern des Arbeitsfensters ab', () => {
// 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt.
expect(
belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster),
).toBe(60)
})
it('lässt Termine außerhalb des Fensters ganz weg', () => {
// 20:00 Berlin, das Fenster endet um 17:00.
expect(
belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster),
).toBe(0)
})
it('lässt ganztägige und freie Termine weg', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }),
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }),
],
fenster,
),
).toBe(0)
})
})
describe('tagesBelegung', () => {
it('rechnet Kapazität und Termine gegeneinander', () => {
const belegung = tagesBelegung(
bereich,
[termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')],
dienstag,
)
expect(belegung.kapazitaet).toBe(240)
expect(belegung.belegt).toBe(120)
expect(belegung.frei).toBe(120)
expect(belegung.prozent).toBe(50)
expect(belegung.ueberbucht).toBe(false)
})
it('erkennt einen überbuchten Tag', () => {
const belegung = tagesBelegung(
bereich,
[termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')],
dienstag,
)
expect(belegung.belegt).toBe(360)
expect(belegung.frei).toBe(0)
expect(belegung.ueberbucht).toBe(true)
expect(belegung.prozent).toBe(150)
})
it('behandelt einen Tag ohne Kapazität als voll', () => {
// Sonntag: keine Kapazität, also gibt es dort nichts zu planen.
const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z'))
expect(belegung.kapazitaet).toBe(0)
expect(belegung.prozent).toBe(100)
expect(belegung.frei).toBe(0)
})
})
describe('freieFenster', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => {
const luecken = freieFenster([], fenster)
expect(luecken).toHaveLength(1)
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
})
it('findet die Lücke zwischen zwei Terminen', () => {
const luecken = freieFenster(
[
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'),
],
fenster,
)
expect(luecken).toHaveLength(1)
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z')
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z')
})
it('lässt zu kurze Lücken weg', () => {
const luecken = freieFenster(
[
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
// Nur zehn Minuten Pause.
termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'),
],
fenster,
15,
)
expect(luecken).toEqual([])
})
it('ist bei durchgehender Belegung leer', () => {
expect(
freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster),
).toEqual([])
})
it('übergeht ganztägige Termine und findet den Tag frei', () => {
const luecken = freieFenster(
[termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })],
fenster,
)
expect(luecken).toHaveLength(1)
})
})
describe('termineAmTag', () => {
it('nimmt, was den Berliner Kalendertag berührt', () => {
const liste = [
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'),
]
expect(termineAmTag(liste, dienstag)).toHaveLength(1)
})
it('nimmt auch einen Termin, der in den Tag hineinragt', () => {
const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z')
expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1)
})
it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => {
// 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag.
const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z')
expect(termineAmTag([davor], dienstag)).toEqual([])
})
})