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, ) }) })