Files
planer/src/lib/zeitblock.test.ts
T
Florian WildeandClaude Opus 5 5791047982 feat(zeitbloecke): Datenmodell und Schleifenschutz
Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem
Kalender steht. Er lebt an zwei Orten — hier und beim Anbieter — und beide
Seiten dürfen ihn verschieben. Daraus entsteht die Gefahr einer
Endlosschleife: Die App schreibt, der Abgleich liest die eigene Änderung,
hält sie für fremd, schreibt zurück, der Abgleich liest wieder.

Der Ausweg ist ein Fingerabdruck über den Zeitraum, sha256(startISO|endeISO).
Beim Schreiben merkt sich der Zeitblock, was er in den Kalender gelegt hat;
kommt derselbe Fingerabdruck zurück, war es die eigene Änderung. Weicht er
ab, hat jemand anderes verschoben — und dann gewinnt der Kalender.

Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch
aus harmlosen Gründen, und für die Planung zählt allein, wann der Block
liegt.

30 Tests, darunter zwei, die den eigentlichen Zweck prüfen: dass es sich
über mehrere Runden nicht hochschaukelt und nach einer übernommenen
fremden Verschiebung wieder zur Ruhe kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:49:52 +02:00

292 lines
8.9 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 {
RASTER_MINUTEN,
ZEITBLOCK_MAX_MINUTEN,
ZEITBLOCK_MIN_MINUTEN,
abgleichEntscheiden,
aufRaster,
dauerMinuten,
lagePruefen,
mussSchreiben,
ueberschneidetSich,
zeitblockSchema,
zeitraumHash,
} from './zeitblock'
const beginn = new Date('2026-03-17T09:00:00Z')
const ende = new Date('2026-03-17T10:00:00Z')
describe('zeitraumHash', () => {
it('ist für denselben Zeitraum gleich', () => {
expect(zeitraumHash(beginn, ende)).toBe(
zeitraumHash(new Date(beginn), new Date(ende)),
)
})
it('ändert sich, wenn der Beginn wandert', () => {
expect(zeitraumHash(beginn, ende)).not.toBe(
zeitraumHash(new Date('2026-03-17T09:15:00Z'), ende),
)
})
it('ändert sich, wenn das Ende wandert', () => {
expect(zeitraumHash(beginn, ende)).not.toBe(
zeitraumHash(beginn, new Date('2026-03-17T10:30:00Z')),
)
})
it('ist unabhängig davon, wie der Zeitpunkt entstanden ist', () => {
// Derselbe Augenblick, anders geschrieben.
expect(zeitraumHash(new Date('2026-03-17T09:00:00.000Z'), ende)).toBe(
zeitraumHash(new Date('2026-03-17T10:00:00+01:00'), ende),
)
})
})
describe('abgleichEntscheiden — der Schleifenschutz', () => {
it('tut nichts, wenn im Kalender genau das steht, was wir geschrieben haben', () => {
// Das ist der Fall, der die Schleife bricht: der Abgleich liest die
// eigene Änderung und erkennt sie am Fingerabdruck wieder.
const entscheidung = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
{ beginn, ende },
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'eigene-aenderung' })
})
it('tut auch ohne Fingerabdruck nichts, wenn beide Seiten übereinstimmen', () => {
const entscheidung = abgleichEntscheiden(
{ hash: null, beginn, ende },
{ beginn, ende },
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
it('übernimmt eine fremde Verschiebung — der Kalender gewinnt', () => {
const verschobenerBeginn = new Date('2026-03-17T11:00:00Z')
const verschobenesEnde = new Date('2026-03-17T12:00:00Z')
const entscheidung = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
{ beginn: verschobenerBeginn, ende: verschobenesEnde },
)
expect(entscheidung).toEqual({
tun: 'uebernehmen',
grund: 'fremd-verschoben',
beginn: verschobenerBeginn,
ende: verschobenesEnde,
})
})
it('erkennt eine fremde Verschiebung auch ohne vorherigen Fingerabdruck', () => {
const entscheidung = abgleichEntscheiden(
{ hash: null, beginn, ende },
{
beginn: new Date('2026-03-17T11:00:00Z'),
ende: new Date('2026-03-17T12:00:00Z'),
},
)
expect(entscheidung.tun).toBe('uebernehmen')
})
it('löst den Zeitblock, wenn der Termin im Kalender verschwunden ist', () => {
const entscheidung = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
null,
)
expect(entscheidung).toEqual({ tun: 'loesen', grund: 'im-kalender-entfernt' })
})
it('schaukelt sich über mehrere Runden nicht hoch', () => {
// Die App schreibt, der Abgleich läuft dreimal – und tut jedes Mal nichts.
let stand = { hash: zeitraumHash(beginn, ende), beginn, ende }
for (let runde = 0; runde < 3; runde++) {
const entscheidung = abgleichEntscheiden(stand, {
beginn: stand.beginn,
ende: stand.ende,
})
expect(entscheidung.tun).toBe('nichts')
stand = { ...stand }
}
})
it('kommt nach einer übernommenen Verschiebung zur Ruhe', () => {
const neuerBeginn = new Date('2026-03-17T11:00:00Z')
const neuesEnde = new Date('2026-03-17T12:00:00Z')
// Erste Runde: fremde Verschiebung wird übernommen.
const erste = abgleichEntscheiden(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
{ beginn: neuerBeginn, ende: neuesEnde },
)
expect(erste.tun).toBe('uebernehmen')
// Danach steht der neue Stand samt Fingerabdruck; die zweite Runde
// erkennt ihn wieder.
const zweite = abgleichEntscheiden(
{
hash: zeitraumHash(neuerBeginn, neuesEnde),
beginn: neuerBeginn,
ende: neuesEnde,
},
{ beginn: neuerBeginn, ende: neuesEnde },
)
expect(zweite.tun).toBe('nichts')
})
})
describe('mussSchreiben', () => {
it('schreibt bei einer echten Verschiebung', () => {
expect(
mussSchreiben(
{ hash: zeitraumHash(beginn, ende), beginn, ende },
new Date('2026-03-17T11:00:00Z'),
new Date('2026-03-17T12:00:00Z'),
),
).toBe(true)
})
it('schreibt nicht, wenn der Zeitraum gleich bleibt', () => {
// Etwa wenn nur der Aufgabentitel geändert wurde.
expect(
mussSchreiben({ hash: zeitraumHash(beginn, ende), beginn, ende }, beginn, ende),
).toBe(false)
})
it('schreibt, solange noch nichts geschrieben wurde', () => {
expect(mussSchreiben({ hash: null, beginn, ende }, beginn, ende)).toBe(true)
})
})
describe('dauerMinuten', () => {
it('rechnet in Minuten', () => {
expect(dauerMinuten(beginn, ende)).toBe(60)
expect(dauerMinuten(beginn, new Date('2026-03-17T09:45:00Z'))).toBe(45)
})
})
describe('aufRaster', () => {
it('rastet auf die nächste Viertelstunde ein', () => {
expect(aufRaster(new Date('2026-03-17T09:07:00Z')).toISOString()).toBe(
'2026-03-17T09:00:00.000Z',
)
expect(aufRaster(new Date('2026-03-17T09:08:00Z')).toISOString()).toBe(
'2026-03-17T09:15:00.000Z',
)
})
it('lässt einen bereits eingerasteten Zeitpunkt in Ruhe', () => {
expect(aufRaster(beginn).toISOString()).toBe(beginn.toISOString())
})
})
describe('lagePruefen', () => {
it('nimmt eine gültige Lage an', () => {
expect(lagePruefen(beginn, ende)).toBeNull()
})
it('weist ein Ende vor dem Beginn ab', () => {
expect(lagePruefen(ende, beginn)).toBe('ende-vor-beginn')
expect(lagePruefen(beginn, beginn)).toBe('ende-vor-beginn')
})
it('weist zu kurze Blöcke ab und nimmt die Untergrenze an', () => {
const knappZuKurz = new Date(beginn.getTime() + (ZEITBLOCK_MIN_MINUTEN - 15) * 60_000)
expect(lagePruefen(beginn, knappZuKurz)).toBe('ende-vor-beginn')
const genauMinimum = new Date(beginn.getTime() + ZEITBLOCK_MIN_MINUTEN * 60_000)
expect(lagePruefen(beginn, genauMinimum)).toBeNull()
})
it('weist zu lange Blöcke ab und nimmt die Obergrenze an', () => {
const genauMaximum = new Date(beginn.getTime() + ZEITBLOCK_MAX_MINUTEN * 60_000)
expect(lagePruefen(beginn, genauMaximum)).toBeNull()
const zuLang = new Date(
beginn.getTime() + (ZEITBLOCK_MAX_MINUTEN + RASTER_MINUTEN) * 60_000,
)
expect(lagePruefen(beginn, zuLang)).toBe('zu-lang')
})
it('weist Zeiten außerhalb des Rasters ab', () => {
expect(lagePruefen(new Date('2026-03-17T09:07:00Z'), ende)).toBe('nicht-im-raster')
expect(lagePruefen(beginn, new Date('2026-03-17T10:07:00Z'))).toBe('nicht-im-raster')
})
})
describe('ueberschneidetSich', () => {
const block = { beginn, ende }
it('erkennt eine Überschneidung', () => {
expect(
ueberschneidetSich(block, {
beginn: new Date('2026-03-17T09:30:00Z'),
ende: new Date('2026-03-17T10:30:00Z'),
}),
).toBe(true)
})
it('zählt reines Aneinandergrenzen nicht', () => {
expect(
ueberschneidetSich(block, {
beginn: ende,
ende: new Date('2026-03-17T11:00:00Z'),
}),
).toBe(false)
})
it('erkennt vollständige Umschließung', () => {
expect(
ueberschneidetSich(block, {
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T12:00:00Z'),
}),
).toBe(true)
})
it('erkennt getrennte Zeiträume', () => {
expect(
ueberschneidetSich(block, {
beginn: new Date('2026-03-17T14:00:00Z'),
ende: new Date('2026-03-17T15:00:00Z'),
}),
).toBe(false)
})
})
describe('zeitblockSchema', () => {
const gueltig = {
aufgabeId: 'a1',
kalenderkontoId: 'k1',
beginn: '2026-03-17T09:00:00.000Z',
ende: '2026-03-17T10:00:00.000Z',
}
it('nimmt eine gültige Eingabe an', () => {
const ergebnis = zeitblockSchema.parse(gueltig)
expect(ergebnis.beginn).toBeInstanceOf(Date)
expect(dauerMinuten(ergebnis.beginn, ergebnis.ende)).toBe(60)
})
it('weist eine unmögliche Lage ab', () => {
expect(
zeitblockSchema.safeParse({ ...gueltig, ende: '2026-03-17T08:00:00.000Z' }).success,
).toBe(false)
})
it('weist Zeiten außerhalb des Rasters ab', () => {
expect(
zeitblockSchema.safeParse({ ...gueltig, beginn: '2026-03-17T09:07:00.000Z' })
.success,
).toBe(false)
})
it('verlangt Aufgabe und Kalenderkonto', () => {
expect(zeitblockSchema.safeParse({ ...gueltig, aufgabeId: '' }).success).toBe(false)
expect(zeitblockSchema.safeParse({ ...gueltig, kalenderkontoId: '' }).success).toBe(
false,
)
})
})