Files
Webklar-Kundenbereich/test/zeitenZeitlogik.test.js
T
ClaudeandClaude Opus 5.5 0f25f7cb52 Öffnungszeiten: Tagesbild und Öffnungsintervalle
Reine Zeitlogik in Europe/Berlin: tagesbild (Sondertag > Urlaub > Feiertag > Wochenplan,
planbezogen vor global, später angelegt gewinnt), oeffnungsIntervalle (zusammengefügt,
Fenster über Mitternacht gehören zum Starttag), zuUtcOderDanach (März-Lücke auf 03:00).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:44:28 +00:00

593 lines
30 KiB
JavaScript
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 { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
hauptplan,
planNachKurz,
zuUtcOderDanach,
tagesbild,
oeffnungsIntervalle,
} from '../server/services/zeiten/zeitlogik.js'
import { ausUtc } from '../server/services/termine/zeit.js'
// Fixture: Wochentage 1..7 (Mo..So). Wert: ['09:00','18:00'] = ein Fenster, [[..],[..]] = zwei Fenster,
// 'rund' / 'vereinbarung' = Modus, fehlt = zu.
function plan(woche = {}, extra = {}) {
const tage = {}
for (let d = 1; d <= 7; d += 1) {
const w = woche[d]
if (!w) tage[d] = { modus: 'zu', fenster: [] }
else if (typeof w === 'string') tage[d] = { modus: w, fenster: [] }
else tage[d] = { modus: 'zeiten', fenster: Array.isArray(w[0]) ? w : [w] }
}
return { id: 'p1', kurz: 'standard', name: 'Standard', haupt: true, tage, ...extra }
}
function daten({ land = 'RP', plaene = [plan({ 1: ['09:00', '18:00'] })], ausnahmen = [], feiertage = {} } = {}) {
return {
land,
plaene,
feiertage,
ausnahmen,
hinweise: [],
kontakt: { an: false, telefon: '', adresse: '', whatsapp: null },
}
}
function tiefEinfrieren(x) {
if (x && typeof x === 'object' && !Object.isFrozen(x)) {
Object.freeze(x)
for (const v of Object.values(x)) tiefEinfrieren(v)
}
return x
}
const urlaub = (extra) => ({ id: 'u', art: 'urlaub', text: 'Betriebsurlaub', modus: 'zu', fenster: [], plan: null, ...extra })
const sondertag = (extra) => ({ id: 's', art: 'sondertag', text: 'Sonderöffnung', modus: 'zeiten', fenster: [['10:00', '14:00']], plan: null, ...extra })
// Berliner Wanduhr eines Zeitpunkts in ms: '2026-10-10 18:00'
const berlin = (ms) => {
const { tag, uhrzeit } = ausUtc(new Date(ms))
return `${tag} ${uhrzeit}`
}
const iv = (liste) => liste.map((i) => [berlin(i.vonMs), berlin(i.bisMs)])
// ---------------------------------------------------------------- Plan-Suche
test('hauptplan liefert den Plan mit haupt: true, sonst null', () => {
const a = plan({}, { id: 'a', kurz: 'sommer', haupt: false })
const b = plan({}, { id: 'b', kurz: 'standard', haupt: true })
assert.equal(hauptplan(daten({ plaene: [a, b] })), b)
assert.equal(hauptplan(daten({ plaene: [a] })), null)
assert.equal(hauptplan(daten({ plaene: [] })), null)
assert.equal(hauptplan({}), null)
assert.equal(hauptplan(null), null)
assert.equal(hauptplan(undefined), null)
assert.equal(hauptplan({ plaene: 'kaputt' }), null)
})
test('planNachKurz findet den Plan über den Kurznamen', () => {
const a = plan({}, { id: 'a', kurz: 'sommer', haupt: false })
const b = plan({}, { id: 'b', kurz: 'standard', haupt: true })
const d = daten({ plaene: [a, b] })
assert.equal(planNachKurz(d, 'sommer'), a)
assert.equal(planNachKurz(d, 'standard'), b)
assert.equal(planNachKurz(d, 'winter'), null)
assert.equal(planNachKurz(d, 'SOMMER'), null, 'Kurznamen sind [a-z0-9-], kein Case-Folding')
assert.equal(planNachKurz(d, undefined), null)
assert.equal(planNachKurz(d, 5), null)
assert.equal(planNachKurz(null, 'sommer'), null)
})
// ---------------------------------------------------------------- zuUtcOderDanach
test('März-Lücke rückt auf 03:00', () => {
assert.equal(zuUtcOderDanach('2026-03-29', '02:30').toISOString(), '2026-03-29T01:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-03-29', '02:00').toISOString(), '2026-03-29T01:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-03-29', '02:59').toISOString(), '2026-03-29T01:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-03-29', '03:00').toISOString(), '2026-03-29T01:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-03-29', '01:59').toISOString(), '2026-03-29T00:59:00.000Z')
})
test('Oktober: doppelte Stunde nimmt das erste Vorkommen', () => {
assert.equal(zuUtcOderDanach('2026-10-25', '02:30').toISOString(), '2026-10-25T00:30:00.000Z')
assert.equal(zuUtcOderDanach('2026-10-25', '03:00').toISOString(), '2026-10-25T02:00:00.000Z')
})
test('zuUtcOderDanach: normale Tage wie zuUtc, 24:00 ist der Folgetag 00:00', () => {
assert.equal(zuUtcOderDanach('2026-10-05', '09:00').toISOString(), '2026-10-05T07:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-01-15', '09:00').toISOString(), '2026-01-15T08:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-10-05', '24:00').toISOString(), '2026-10-05T22:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-10-24', '24:00').toISOString(), '2026-10-24T22:00:00.000Z', 'Mitternacht vor der Umstellung: Sommerzeit')
assert.equal(zuUtcOderDanach('2026-10-25', '24:00').toISOString(), '2026-10-25T23:00:00.000Z', 'Mitternacht nach der Umstellung: Winterzeit')
assert.equal(zuUtcOderDanach('2026-12-31', '24:00').toISOString(), '2026-12-31T23:00:00.000Z')
assert.equal(zuUtcOderDanach('2026-03-28', '24:00').toISOString(), '2026-03-28T23:00:00.000Z')
})
test('zuUtcOderDanach wirft bei ungültiger Eingabe (nur die Lücke wird überbrückt)', () => {
assert.throws(() => zuUtcOderDanach('2026-02-30', '09:00'), RangeError)
assert.throws(() => zuUtcOderDanach('2026-10-05', '25:00'), RangeError)
assert.throws(() => zuUtcOderDanach('2026-10-05', '9:00'), RangeError)
assert.throws(() => zuUtcOderDanach('2026-10-05', '24:30'), RangeError)
assert.throws(() => zuUtcOderDanach(undefined, '09:00'), RangeError)
assert.throws(() => zuUtcOderDanach('2026-10-05'), RangeError)
})
// ---------------------------------------------------------------- tagesbild: Vorrang
test('Wochenplan an einem normalen Montag', () => {
const d = daten()
assert.deepEqual(tagesbild(d, hauptplan(d), '2026-10-05'), {
tag: '2026-10-05',
art: 'regulaer',
name: null,
modus: 'zeiten',
fenster: [['09:00', '18:00']],
})
})
test('Wochenplan: zwei Fenster, geschlossene Tage, rund und nach Vereinbarung', () => {
const p = plan({ 1: [['09:00', '12:30'], ['13:30', '18:00']], 3: 'rund', 4: 'vereinbarung' })
const d = daten({ plaene: [p] })
assert.deepEqual(tagesbild(d, p, '2026-10-05').fenster, [['09:00', '12:30'], ['13:30', '18:00']])
assert.deepEqual(tagesbild(d, p, '2026-10-06'), { tag: '2026-10-06', art: 'regulaer', name: null, modus: 'zu', fenster: [] })
assert.deepEqual(tagesbild(d, p, '2026-10-07'), { tag: '2026-10-07', art: 'regulaer', name: null, modus: 'rund', fenster: [] })
assert.deepEqual(tagesbild(d, p, '2026-10-08'), { tag: '2026-10-08', art: 'regulaer', name: null, modus: 'vereinbarung', fenster: [] })
assert.equal(tagesbild(d, p, '2026-10-11').modus, 'zu', 'Sonntag ohne Eintrag')
})
test('fehlende oder kaputte Tageseinträge ergeben „zu“, kaputte Fenster fallen heraus', () => {
const p = { id: 'p1', kurz: 'standard', name: 'S', haupt: true, tage: { 1: { modus: 'zeiten', fenster: [['9:00', '18:00'], ['09:00'], 'x', ['10:00', '10:00'], ['24:00', '02:00'], ['09:00', '25:00']] }, 2: { modus: 'unbekannt', fenster: [['09:00', '18:00']] }, 3: { modus: 'zeiten', fenster: [['09:00', '12:00'], ['kaputt', '18:00']] } } }
const d = daten({ plaene: [p] })
assert.deepEqual(tagesbild(d, p, '2026-10-05'), { tag: '2026-10-05', art: 'regulaer', name: null, modus: 'zu', fenster: [] }, 'Modus zeiten ohne brauchbares Fenster = zu')
assert.equal(tagesbild(d, p, '2026-10-06').modus, 'zu', 'unbekannter Modus')
assert.deepEqual(tagesbild(d, p, '2026-10-07').fenster, [['09:00', '12:00']])
assert.equal(tagesbild(d, p, '2026-10-08').modus, 'zu', 'Tag fehlt')
assert.equal(tagesbild(d, { id: 'x', tage: null }, '2026-10-05').modus, 'zu')
assert.equal(tagesbild(d, null, '2026-10-05').modus, 'zu', 'ohne Plan nur Ausnahmen')
})
test('Fenster werden nach Beginn sortiert und kopiert', () => {
const p = plan({ 1: [['13:30', '18:00'], ['09:00', '12:30']] })
const d = daten({ plaene: [p] })
const t = tagesbild(d, p, '2026-10-05')
assert.deepEqual(t.fenster, [['09:00', '12:30'], ['13:30', '18:00']])
t.fenster[0][0] = '00:00'
t.fenster.push(['20:00', '21:00'])
assert.deepEqual(tagesbild(d, p, '2026-10-05').fenster, [['09:00', '12:30'], ['13:30', '18:00']], 'Rückgabe teilt keine Objekte mit den Daten')
assert.deepEqual(p.tage[1].fenster, [['13:30', '18:00'], ['09:00', '12:30']], 'Eingabe bleibt unverändert')
})
test('Feiertag ist zu, mit Namen', () => {
const d = daten()
assert.deepEqual(tagesbild(d, hauptplan(d), '2026-10-03'), {
tag: '2026-10-03',
art: 'feiertag',
name: 'Tag der Deutschen Einheit',
modus: 'zu',
fenster: [],
})
})
test('Feiertag gilt auch an einem Tag, an dem der Plan offen wäre', () => {
const p = plan({ 6: ['10:00', '16:00'] })
const d = daten({ plaene: [p] })
const t = tagesbild(d, p, '2026-10-03') // Samstag
assert.equal(t.art, 'feiertag')
assert.equal(t.modus, 'zu')
})
test('Feiertag hängt vom Bundesland ab; fehlendes oder ungültiges Land = RP', () => {
const p = plan({ 1: ['09:00', '18:00'] })
// Fronleichnam 2026 = 04.06. (Donnerstag): in RP gesetzlich, in BE nicht
const ohneLand = daten({ plaene: [p] })
delete ohneLand.land
assert.equal(tagesbild(daten({ land: 'RP', plaene: [p] }), p, '2026-06-04').art, 'feiertag')
assert.equal(tagesbild(daten({ land: 'BE', plaene: [p] }), p, '2026-06-04').art, 'regulaer')
assert.equal(tagesbild(ohneLand, p, '2026-06-04').art, 'feiertag', 'Vorgabe RP')
assert.equal(tagesbild(daten({ land: 'XX', plaene: [p] }), p, '2026-06-04').art, 'feiertag', 'ungültiges Land: Vorgabe RP')
assert.equal(tagesbild(daten({ land: 'rp', plaene: [p] }), p, '2026-06-04').art, 'feiertag', 'Kleinschreibung: Vorgabe RP')
assert.equal(tagesbild({ plaene: [p] }, p, '2026-10-03').name, 'Tag der Deutschen Einheit', 'daten ohne Land, Ausnahmen und Feiertage')
})
test('Feiertags-Abweichung gilt', () => {
const d = daten({ feiertage: { '2026-10-03': { modus: 'zeiten', fenster: [['10:00', '14:00']] } } })
assert.deepEqual(tagesbild(d, hauptplan(d), '2026-10-03'), {
tag: '2026-10-03',
art: 'feiertag',
name: 'Tag der Deutschen Einheit',
modus: 'zeiten',
fenster: [['10:00', '14:00']],
})
const rund = daten({ feiertage: { '2026-10-03': { modus: 'rund', fenster: [] } } })
assert.equal(tagesbild(rund, hauptplan(rund), '2026-10-03').modus, 'rund')
const verein = daten({ feiertage: { '2026-10-03': { modus: 'vereinbarung', fenster: [] } } })
assert.equal(tagesbild(verein, hauptplan(verein), '2026-10-03').modus, 'vereinbarung')
})
test('Feiertags-Abweichung auf einem Tag ohne Feiertag oder mit kaputtem Inhalt wirkt nicht', () => {
const d = daten({ feiertage: { '2026-10-05': { modus: 'zu', fenster: [] }, '2026-10-03': { modus: 'kaputt' } } })
const p = hauptplan(d)
assert.equal(tagesbild(d, p, '2026-10-05').modus, 'zeiten', 'kein Feiertag: Wochenplan')
assert.equal(tagesbild(d, p, '2026-10-03').modus, 'zu', 'kaputte Abweichung: Vorgabe des Feiertags')
assert.equal(tagesbild(d, p, '2026-10-03').art, 'feiertag')
})
test('Prüftag folgt dem Wochenplan', () => {
const p = plan({ 4: ['09:00', '16:00'] })
const d = daten({ plaene: [p] })
assert.deepEqual(tagesbild(d, p, '2026-12-24'), {
tag: '2026-12-24',
art: 'regulaer',
name: null,
modus: 'zeiten',
fenster: [['09:00', '16:00']],
})
// Silvester 2026 (Do) ebenso; 1. Weihnachtstag 2026 (Fr) ist gesetzlich
assert.equal(tagesbild(d, p, '2026-12-31').art, 'regulaer')
assert.equal(tagesbild(d, p, '2026-12-25').art, 'feiertag')
// Mariä Himmelfahrt: in BY ein Prüftag, in SL gesetzlich
const by = daten({ land: 'BY', plaene: [plan({ 6: ['09:00', '12:00'] })] })
assert.equal(tagesbild(by, by.plaene[0], '2026-08-15').art, 'regulaer')
const sl = daten({ land: 'SL', plaene: [plan({ 6: ['09:00', '12:00'] })] })
assert.equal(tagesbild(sl, sl.plaene[0], '2026-08-15').art, 'feiertag')
})
test('Prüftag mit gespeicherter Abweichung: die Abweichung gilt, der Name bleibt', () => {
const p = plan({ 4: ['09:00', '18:00'] })
const d = daten({ plaene: [p], feiertage: { '2026-12-24': { modus: 'zeiten', fenster: [['09:00', '13:00']] } } })
assert.deepEqual(tagesbild(d, p, '2026-12-24'), {
tag: '2026-12-24',
art: 'feiertag',
name: 'Heiligabend',
modus: 'zeiten',
fenster: [['09:00', '13:00']],
})
const zu = daten({ plaene: [p], feiertage: { '2026-12-24': { modus: 'zu', fenster: [] } } })
assert.equal(tagesbild(zu, p, '2026-12-24').modus, 'zu')
})
test('Urlaub schlägt Feiertag, Sondertag schlägt Urlaub', () => {
const p = plan({ 1: ['09:00', '18:00'], 6: ['10:00', '16:00'] })
// Feiertag Sa 03.10.: Urlaub zu; Sondertag offen
const nurUrlaub = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-01', bis: '2026-10-06', text: 'Herbstpause' })] })
assert.deepEqual(tagesbild(nurUrlaub, p, '2026-10-03'), {
tag: '2026-10-03',
art: 'urlaub',
name: 'Herbstpause',
modus: 'zu',
fenster: [],
})
const urlaubUndFeiertagsAbweichung = daten({
plaene: [p],
ausnahmen: [urlaub({ von: '2026-10-01', bis: '2026-10-06' })],
feiertage: { '2026-10-03': { modus: 'zeiten', fenster: [['10:00', '14:00']] } },
})
assert.equal(tagesbild(urlaubUndFeiertagsAbweichung, p, '2026-10-03').art, 'urlaub', 'Urlaub schlägt auch die Feiertags-Abweichung')
const mitSondertag = daten({
plaene: [p],
ausnahmen: [urlaub({ von: '2026-10-01', bis: '2026-10-06' }), sondertag({ von: '2026-10-03', bis: '2026-10-03', text: 'Tag der offenen Tür' })],
})
assert.deepEqual(tagesbild(mitSondertag, p, '2026-10-03'), {
tag: '2026-10-03',
art: 'sondertag',
name: 'Tag der offenen Tür',
modus: 'zeiten',
fenster: [['10:00', '14:00']],
})
assert.equal(tagesbild(mitSondertag, p, '2026-10-05').art, 'urlaub', 'Montag im Urlaub, kein Sondertag')
assert.equal(tagesbild(mitSondertag, p, '2026-10-07').art, 'regulaer', 'nach dem Urlaub')
assert.equal(tagesbild(mitSondertag, p, '2026-09-30').art, 'regulaer', 'vor dem Urlaub')
})
test('Urlaub ist immer zu, auch wenn der gespeicherte Modus etwas anderes sagt', () => {
const p = plan({ 1: ['09:00', '18:00'] })
const d = daten({ plaene: [p], ausnahmen: [{ id: 'u', art: 'urlaub', von: '2026-10-05', bis: '2026-10-05', plan: null }] })
const t = tagesbild(d, p, '2026-10-05')
assert.equal(t.art, 'urlaub')
assert.equal(t.modus, 'zu')
assert.deepEqual(t.fenster, [])
assert.equal(t.name, null, 'ohne Text kein Name')
})
test('Sondertag mit Modus zu, rund und nach Vereinbarung', () => {
const p = plan({ 1: ['09:00', '18:00'] })
for (const modus of ['zu', 'rund', 'vereinbarung']) {
const d = daten({ plaene: [p], ausnahmen: [sondertag({ von: '2026-10-05', bis: '2026-10-05', modus, fenster: [] })] })
const t = tagesbild(d, p, '2026-10-05')
assert.equal(t.art, 'sondertag')
assert.equal(t.modus, modus)
assert.deepEqual(t.fenster, [])
}
})
test('Ausnahme gilt von bis bis einschließlich, Grenzen inklusive', () => {
const p = plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-06', bis: '2026-10-07' })] })
assert.equal(tagesbild(d, p, '2026-10-05').art, 'regulaer')
assert.equal(tagesbild(d, p, '2026-10-06').art, 'urlaub')
assert.equal(tagesbild(d, p, '2026-10-07').art, 'urlaub')
assert.equal(tagesbild(d, p, '2026-10-08').art, 'regulaer')
})
test('planbezogene Ausnahme schlägt globale gleicher Art', () => {
const sommer = plan({ 1: ['09:00', '18:00'] }, { id: 'p-sommer', kurz: 'sommer', haupt: false })
const haupt = plan({ 1: ['09:00', '18:00'] }, { id: 'p-haupt', kurz: 'standard', haupt: true })
const ausnahmen = [
// planbezogene Ausnahme steht VOR der globalen, schlägt sie trotzdem
urlaub({ id: 'u1', von: '2026-10-05', bis: '2026-10-09', text: 'Nur Sommerplan', plan: 'p-sommer' }),
urlaub({ id: 'u2', von: '2026-10-05', bis: '2026-10-09', text: 'Alle Pläne', plan: null }),
]
const d = daten({ plaene: [haupt, sommer], ausnahmen })
assert.equal(tagesbild(d, sommer, '2026-10-05').name, 'Nur Sommerplan')
assert.equal(tagesbild(d, haupt, '2026-10-05').name, 'Alle Pläne')
})
test('Ausnahme eines anderen Plans gilt nicht', () => {
const sommer = plan({ 1: ['09:00', '18:00'] }, { id: 'p-sommer', kurz: 'sommer', haupt: false })
const haupt = plan({ 1: ['09:00', '18:00'] }, { id: 'p-haupt', kurz: 'standard', haupt: true })
const d = daten({ plaene: [haupt, sommer], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-05', plan: 'p-sommer' })] })
assert.equal(tagesbild(d, sommer, '2026-10-05').art, 'urlaub')
assert.equal(tagesbild(d, haupt, '2026-10-05').art, 'regulaer')
})
test('Die Art schlägt die Planbezogenheit: globaler Sondertag vor planbezogenem Urlaub', () => {
const haupt = plan({ 1: ['09:00', '18:00'] }, { id: 'p-haupt', kurz: 'standard', haupt: true })
const d = daten({
plaene: [haupt],
ausnahmen: [
urlaub({ id: 'u1', von: '2026-10-05', bis: '2026-10-05', plan: 'p-haupt' }),
sondertag({ id: 's1', von: '2026-10-05', bis: '2026-10-05', plan: null, text: 'Alle' }),
],
})
assert.equal(tagesbild(d, haupt, '2026-10-05').art, 'sondertag')
})
test('zwei überlappende Urlaube: der später angelegte gilt', () => {
const p = plan({ 1: ['09:00', '18:00'] })
const a = urlaub({ id: 'a', von: '2026-10-05', bis: '2026-10-12', text: 'Erster' })
const b = urlaub({ id: 'b', von: '2026-10-05', bis: '2026-10-07', text: 'Zweiter' })
assert.equal(tagesbild(daten({ plaene: [p], ausnahmen: [a, b] }), p, '2026-10-05').name, 'Zweiter')
assert.equal(tagesbild(daten({ plaene: [p], ausnahmen: [b, a] }), p, '2026-10-05').name, 'Erster', 'höherer Index = später angelegt')
assert.equal(tagesbild(daten({ plaene: [p], ausnahmen: [a, b] }), p, '2026-10-10').name, 'Erster', 'außerhalb des späteren gilt der frühere')
})
test('zwei überlappende Sondertage: der später angelegte gilt', () => {
const p = plan({ 1: ['09:00', '18:00'] })
const a = sondertag({ id: 'a', von: '2026-10-05', bis: '2026-10-05', text: 'Erster', fenster: [['10:00', '12:00']] })
const b = sondertag({ id: 'b', von: '2026-10-05', bis: '2026-10-05', text: 'Zweiter', fenster: [['15:00', '17:00']] })
const t = tagesbild(daten({ plaene: [p], ausnahmen: [a, b] }), p, '2026-10-05')
assert.equal(t.name, 'Zweiter')
assert.deepEqual(t.fenster, [['15:00', '17:00']])
})
test('Urlaub über den Jahreswechsel', () => {
const p = plan({ 6: ['10:00', '16:00'], 7: ['10:00', '16:00'], 1: ['09:00', '18:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-12-28', bis: '2027-01-03' })] })
for (const tag of ['2026-12-28', '2026-12-31', '2027-01-01', '2027-01-02', '2027-01-03']) {
assert.equal(tagesbild(d, p, tag).art, 'urlaub', tag)
}
assert.equal(tagesbild(d, p, '2026-12-27').art, 'regulaer')
assert.equal(tagesbild(d, p, '2027-01-04').art, 'regulaer')
})
test('Ausnahmen mit fehlendem oder umgekehrtem Zeitraum werden ignoriert, fehlendes bis = ein Tag', () => {
const p = plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'] })
const d = daten({
plaene: [p],
ausnahmen: [
{ id: 'x', art: 'urlaub', von: '2026-10-06', bis: '2026-10-05', modus: 'zu', fenster: [], plan: null },
{ id: 'y', art: 'urlaub', von: undefined, bis: undefined, modus: 'zu', fenster: [], plan: null },
{ id: 'z', art: 'ferien', von: '2026-10-05', bis: '2026-10-05', modus: 'zu', fenster: [], plan: null },
{ id: 'w', art: 'urlaub', von: '2026-10-06', text: 'ein Tag', modus: 'zu', fenster: [], plan: null },
null,
],
})
assert.equal(tagesbild(d, p, '2026-10-05').art, 'regulaer')
assert.equal(tagesbild(d, p, '2026-10-06').art, 'urlaub')
assert.equal(tagesbild(d, p, '2026-10-07').art, 'regulaer')
})
test('tagesbild rechnet nichts in die Daten zurück und wirft bei ungültigem Tag', () => {
const d = tiefEinfrieren(daten({ ausnahmen: [urlaub({ von: '2026-10-06', bis: '2026-10-07' })], feiertage: { '2026-10-03': { modus: 'zeiten', fenster: [['10:00', '14:00']] } } }))
const p = hauptplan(d)
assert.doesNotThrow(() => tagesbild(d, p, '2026-10-03'))
assert.doesNotThrow(() => tagesbild(d, p, '2026-10-06'))
assert.doesNotThrow(() => oeffnungsIntervalle(d, p, '2026-10-01', '2026-10-10'))
for (const tag of [undefined, null, '', '2026-10-3', '03.10.2026', '2026-02-30', 5]) {
assert.throws(() => tagesbild(d, p, tag), RangeError, String(tag))
}
})
// ---------------------------------------------------------------- oeffnungsIntervalle
test('einfaches Fenster wird ein Intervall in Berliner Zeit', () => {
const d = daten()
const r = oeffnungsIntervalle(d, hauptplan(d), '2026-10-05', '2026-10-05')
assert.deepEqual(r, [{ vonMs: Date.parse('2026-10-05T07:00:00.000Z'), bisMs: Date.parse('2026-10-05T16:00:00.000Z') }])
})
test('zwei Fenster am Tag bleiben getrennt, Wochen über mehrere Tage', () => {
const p = plan({ 1: [['09:00', '12:30'], ['13:30', '18:00']], 2: ['09:00', '12:00'] })
const d = daten({ plaene: [p] })
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-05', '2026-10-11')), [
['2026-10-05 09:00', '2026-10-05 12:30'],
['2026-10-05 13:30', '2026-10-05 18:00'],
['2026-10-06 09:00', '2026-10-06 12:00'],
])
})
test('aneinander grenzende Fenster werden zusammengefügt', () => {
const p = plan({ 1: [['09:00', '12:00'], ['12:00', '18:00']] })
const d = daten({ plaene: [p] })
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-05', '2026-10-05')), [['2026-10-05 09:00', '2026-10-05 18:00']])
})
test('Fenster über Mitternacht gehört zum Starttag', () => {
// Bar: Samstag 18:00–02:00; Sonntag ist (in Brandenburg) Feiertag, Ostersonntag 2026-04-05
const p = plan({ 6: ['18:00', '02:00'] })
const d = daten({ land: 'BB', plaene: [p] })
assert.equal(tagesbild(d, p, '2026-04-05').art, 'feiertag')
const r = oeffnungsIntervalle(d, p, '2026-04-04', '2026-04-04')
assert.equal(r.length, 1)
assert.equal(new Date(r[0].vonMs).toISOString(), '2026-04-04T16:00:00.000Z')
assert.equal(new Date(r[0].bisMs).toISOString(), '2026-04-05T00:00:00.000Z')
assert.deepEqual(iv(r), [['2026-04-04 18:00', '2026-04-05 02:00']])
// auch wenn der Folgetag Urlaub oder ein geschlossener Sondertag ist
const urlaubSo = daten({ land: 'RP', plaene: [p], ausnahmen: [urlaub({ von: '2026-10-11', bis: '2026-10-11' })] })
assert.deepEqual(iv(oeffnungsIntervalle(urlaubSo, p, '2026-10-10', '2026-10-11')), [['2026-10-10 18:00', '2026-10-11 02:00']])
})
test('Intervalle enthalten das in vonTag hineinlaufende Fenster des Vortags', () => {
const p = plan({ 6: ['18:00', '02:00'] })
const d = daten({ plaene: [p] })
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-11', '2026-10-11')), [['2026-10-10 18:00', '2026-10-11 02:00']])
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-12', '2026-10-12')), [], 'Fenster endet vor vonTag: nicht dabei')
})
test('Fenster, das genau um 00:00 endet, läuft nicht in vonTag hinein', () => {
const p = plan({ 6: ['18:00', '24:00'] })
const d = daten({ plaene: [p] })
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-11', '2026-10-11')), [])
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-10', '2026-10-10')), [['2026-10-10 18:00', '2026-10-11 00:00']])
})
test('Nächtliches Fenster am letzten Tag des Bereichs läuft bis zu seinem Ende', () => {
const p = plan({ 6: ['18:00', '02:00'] })
const d = daten({ plaene: [p] })
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-10', '2026-10-10')), [['2026-10-10 18:00', '2026-10-11 02:00']])
})
test('Mitternachtsfenster 18:00–00:00 und Folgetag ab 00:00 werden ein Intervall', () => {
const p = plan({ 1: ['18:00', '00:00'], 2: ['00:00', '06:00'] })
const d = daten({ plaene: [p] })
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-05', '2026-10-06')), [['2026-10-05 18:00', '2026-10-06 06:00']])
})
test('rund um die Uhr an zwei Tagen wird ein Intervall', () => {
const p = plan({ 1: 'rund', 2: 'rund' })
const d = daten({ plaene: [p] })
const r = oeffnungsIntervalle(d, p, '2026-10-05', '2026-10-11')
assert.equal(r.length, 1)
assert.deepEqual(iv(r), [['2026-10-05 00:00', '2026-10-07 00:00']])
assert.equal(new Date(r[0].vonMs).toISOString(), '2026-10-04T22:00:00.000Z')
assert.equal(new Date(r[0].bisMs).toISOString(), '2026-10-06T22:00:00.000Z')
})
test('rund und ein Fenster am Folgetag werden zusammengefügt, nicht benachbarte bleiben getrennt', () => {
const p = plan({ 1: 'rund', 2: ['00:00', '08:00'], 4: 'rund' })
const d = daten({ plaene: [p] })
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-05', '2026-10-11')), [
['2026-10-05 00:00', '2026-10-06 08:00'],
['2026-10-08 00:00', '2026-10-09 00:00'],
])
})
test('nach Vereinbarung und zu erzeugen kein Intervall', () => {
const p = plan({ 1: 'vereinbarung', 2: 'zu' })
const d = daten({ plaene: [p] })
assert.deepEqual(oeffnungsIntervalle(d, p, '2026-10-05', '2026-10-11'), [])
})
test('Feiertag, Urlaub und Sondertag wirken auf die Intervalle', () => {
const p = plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 6: ['10:00', '16:00'] })
const d = daten({
plaene: [p],
ausnahmen: [urlaub({ von: '2026-10-06', bis: '2026-10-06' }), sondertag({ von: '2026-10-07', bis: '2026-10-07', fenster: [['12:00', '13:00']] })],
})
assert.deepEqual(iv(oeffnungsIntervalle(d, p, '2026-10-03', '2026-10-07')), [
// 03.10. Feiertag (zu), 05.10. regulär, 06.10. Urlaub, 07.10. Sondertag
['2026-10-05 09:00', '2026-10-05 18:00'],
['2026-10-07 12:00', '2026-10-07 13:00'],
])
})
test('Feiertags-Abweichung öffnet an einem Feiertag', () => {
const d = daten({ feiertage: { '2026-10-03': { modus: 'zeiten', fenster: [['10:00', '14:00']] } } })
assert.deepEqual(iv(oeffnungsIntervalle(d, hauptplan(d), '2026-10-03', '2026-10-03')), [['2026-10-03 10:00', '2026-10-03 14:00']])
})
test('Sommerzeit: Fenster in der März-Lücke rückt auf 03:00, Länge in echter Zeit', () => {
// So 29.03.2026: 02:00–03:00 springt, Fenster 02:30–05:00 beginnt um 03:00 (01:00Z) und endet 05:00 CEST (03:00Z)
const p = plan({ 7: ['02:30', '05:00'] })
const d = daten({ plaene: [p] })
const r = oeffnungsIntervalle(d, p, '2026-03-29', '2026-03-29')
assert.equal(new Date(r[0].vonMs).toISOString(), '2026-03-29T01:00:00.000Z')
assert.equal(new Date(r[0].bisMs).toISOString(), '2026-03-29T03:00:00.000Z')
// Fenster, das ganz in der Lücke liegt, verschwindet
const lueckenfenster = plan({ 7: ['02:10', '02:50'] })
assert.deepEqual(oeffnungsIntervalle(daten({ plaene: [lueckenfenster] }), lueckenfenster, '2026-03-29', '2026-03-29'), [])
// Ende in der Lücke: bis 02:30 ⇒ 03:00
const endeInLuecke = plan({ 7: ['00:00', '02:30'] })
const r2 = oeffnungsIntervalle(daten({ plaene: [endeInLuecke] }), endeInLuecke, '2026-03-29', '2026-03-29')
assert.equal(new Date(r2[0].bisMs).toISOString(), '2026-03-29T01:00:00.000Z')
})
test('Sommerzeit: Tag „rund“ ist am Umstellungstag 23 bzw. 25 Stunden lang', () => {
const p = plan({ 7: 'rund' })
const d = daten({ plaene: [p] })
const stunden = (tag) => {
const [i] = oeffnungsIntervalle(d, p, tag, tag)
return (i.bisMs - i.vonMs) / 3600000
}
assert.equal(stunden('2026-03-29'), 23)
assert.equal(stunden('2026-10-25'), 25)
assert.equal(stunden('2026-10-18'), 24)
})
test('Oktober: Fenster 02:00–06:00 am Umstellungstag, doppelte Stunde zählt mit', () => {
const p = plan({ 7: ['02:00', '06:00'] })
const d = daten({ plaene: [p] })
const [i] = oeffnungsIntervalle(d, p, '2026-10-25', '2026-10-25')
assert.equal(new Date(i.vonMs).toISOString(), '2026-10-25T00:00:00.000Z', '02:00 Sommerzeit')
assert.equal(new Date(i.bisMs).toISOString(), '2026-10-25T05:00:00.000Z', '06:00 Winterzeit')
assert.equal((i.bisMs - i.vonMs) / 3600000, 5)
})
test('Nächtliches Fenster über die Zeitumstellung: Ende in der Wanduhr des Folgetags', () => {
const p = plan({ 6: ['22:00', '02:00'] }) // Sa 24.10. auf So 25.10. (Ende der Sommerzeit)
const d = daten({ plaene: [p] })
const [i] = oeffnungsIntervalle(d, p, '2026-10-24', '2026-10-24')
assert.equal(new Date(i.vonMs).toISOString(), '2026-10-24T20:00:00.000Z')
assert.equal(new Date(i.bisMs).toISOString(), '2026-10-25T00:00:00.000Z', '02:00 erstes Vorkommen = Sommerzeit')
})
test('Intervalle: Bereichsgrenzen, umgekehrter Bereich, ungültige Tage', () => {
const d = daten()
const p = hauptplan(d)
assert.deepEqual(oeffnungsIntervalle(d, p, '2026-10-06', '2026-10-05'), [])
assert.throws(() => oeffnungsIntervalle(d, p, 'x', '2026-10-05'), RangeError)
assert.throws(() => oeffnungsIntervalle(d, p, '2026-10-05', undefined), RangeError)
assert.throws(() => oeffnungsIntervalle(d, p, '2026-02-30', '2026-03-01'), RangeError)
})
test('Intervalle über 60 Tage und mehrere Jahre sind sortiert und überschneidungsfrei', () => {
const p = plan({ 1: [['09:00', '12:00'], ['13:00', '22:00']], 2: ['09:00', '12:00'], 4: ['20:00', '03:00'], 5: 'rund', 6: ['00:00', '08:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-12-28', bis: '2027-01-03' })] })
const r = oeffnungsIntervalle(d, p, '2026-12-01', '2027-02-15')
assert.ok(r.length > 20)
for (let k = 0; k < r.length; k += 1) {
assert.ok(r[k].bisMs > r[k].vonMs)
if (k > 0) assert.ok(r[k].vonMs > r[k - 1].bisMs, 'sortiert, mit Lücke (grenzende wurden zusammengefügt)')
}
// im Urlaub (28.12.–03.01.) beginnt kein Fenster, ein hineinlaufendes des 27.12. ist erlaubt
const imUrlaub = r.filter((i) => i.vonMs >= Date.parse('2026-12-27T23:00:00Z') && i.vonMs < Date.parse('2027-01-03T23:00:00Z'))
assert.deepEqual(imUrlaub, [])
})
test('Rückgabe der Intervalle ist reine Zahlen-Struktur ohne Seiteneffekt auf die Daten', () => {
const d = tiefEinfrieren(daten({ plaene: [plan({ 1: [['09:00', '12:00'], ['13:00', '18:00']], 6: ['22:00', '02:00'] })] }))
const r = oeffnungsIntervalle(d, hauptplan(d), '2026-10-05', '2026-10-11')
assert.ok(r.every((i) => Object.keys(i).sort().join() === 'bisMs,vonMs' && Number.isInteger(i.vonMs) && Number.isInteger(i.bisMs)))
})
// ---------------------------------------------------------------- Reinheit
test('zeitlogik.js ist rein: keine Appwrite- und keine Config-Importe, keine Millisekunden-Tagesrechnung', () => {
const quelle = readFileSync(new URL('../server/services/zeiten/zeitlogik.js', import.meta.url), 'utf8')
const importe = [...quelle.matchAll(/^import[\s\S]*?from\s+['"]([^'"]+)['"]/gm)].map((m) => m[1])
assert.deepEqual(importe.sort(), ['../termine/feiertage.js', '../termine/zeit.js'])
assert.doesNotMatch(quelle, /86400000|864e5/, 'Kalendertage nie mit +86400000')
assert.doesNotMatch(quelle, /Date\.now|new Date\(\)/, 'now kommt immer als Parameter')
})