Files
Webklar-Kundenbereich/test/zeitenZeitlogik.test.js
T
2026-10-04 23:59:20 +00:00

1015 lines
56 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,
uhrText,
statusZu,
wechselListe,
tageListe,
} from '../server/services/zeiten/zeitlogik.js'
import { ausUtc, zuUtc } 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)))
})
// ---------------------------------------------------------------- Status und Texte (Task 5)
// Mo 05.10.2026 10:00 Berlin (MESZ)
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const WOCHE_ALLE_RUND = { 1: 'rund', 2: 'rund', 3: 'rund', 4: 'rund', 5: 'rund', 6: 'rund', 7: 'rund' }
const WOCHE_ALLE_9_18 = { 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '18:00'], 7: ['09:00', '18:00'] }
const status = (d, tag, hhmm) => statusZu(d, hauptplan(d), zuUtc(tag, hhmm))
const nurStatus = (s) => ({ offen: s.offen, text: s.text, kurz: s.kurz })
test('uhrText: volle Stunde ohne Minuten, sonst H:MM, Mitternacht für 00:00 und 24:00', () => {
assert.equal(uhrText('09:00'), '9 Uhr')
assert.equal(uhrText('18:30'), '18:30 Uhr')
assert.equal(uhrText('09:05'), '9:05 Uhr')
assert.equal(uhrText('12:00'), '12 Uhr')
assert.equal(uhrText('23:55'), '23:55 Uhr')
assert.equal(uhrText('00:00'), 'Mitternacht')
assert.equal(uhrText('24:00'), 'Mitternacht')
assert.equal(uhrText('00:30'), '0:30 Uhr')
for (const falsch of ['', '9:00', '24:30', '25:00', '12:60', 'abc', null, undefined, 900]) {
assert.throws(() => uhrText(falsch), RangeError, String(falsch))
}
})
test('offen: schließt um 18 Uhr', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' })
// Grenzen: Beginn inklusive, Ende exklusive
assert.equal(status(d, '2026-10-05', '09:00').offen, true)
assert.equal(status(d, '2026-10-05', '17:59').offen, true)
assert.equal(status(d, '2026-10-05', '18:00').offen, false)
assert.equal(statusZu(d, hauptplan(d), new Date(zuUtc('2026-10-05', '09:00').getTime() - 1)).offen, false)
// Sekunden zählen mit: 17:59:59 ist noch offen
assert.equal(statusZu(d, hauptplan(d), new Date(zuUtc('2026-10-05', '18:00').getTime() - 1000)).offen, true)
})
test('offen: Schluss mit Minuten „schließt um 18:30 Uhr“, zwei Fenster am Tag', () => {
const d = daten({ plaene: [plan({ 1: [['09:00', '12:30'], ['13:30', '18:30']] })] })
assert.equal(status(d, '2026-10-05', '10:00').text, 'Jetzt geöffnet · schließt um 12:30 Uhr')
assert.equal(status(d, '2026-10-05', '12:30').text, 'Geschlossen · öffnet heute um 13:30 Uhr')
assert.equal(status(d, '2026-10-05', '14:00').text, 'Jetzt geöffnet · schließt um 18:30 Uhr')
})
test('Schluss um Mitternacht: „schließt um Mitternacht“ (00:00 und 24:00)', () => {
for (const ende of ['00:00', '24:00']) {
const d = daten({ plaene: [plan({ 1: ['18:00', ende] })] })
assert.deepEqual(status(d, '2026-10-05', '20:00'), { offen: true, text: 'Jetzt geöffnet · schließt um Mitternacht', kurz: 'Geöffnet' }, ende)
assert.equal(status(d, '2026-10-06', '00:00').offen, false, ende)
}
})
test('Schluss mehr als 24 h entfernt: „bis Dienstag 14 Uhr“', () => {
const d = daten({ plaene: [plan({ 1: ['10:00', '24:00'], 2: ['00:00', '14:00'] })] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Jetzt geöffnet · bis Dienstag 14 Uhr', kurz: 'Geöffnet' })
assert.equal(status(d, '2026-10-05', '13:59').text, 'Jetzt geöffnet · bis Dienstag 14 Uhr')
// genau 24 h vor dem Schluss ist es nicht „mehr als 24 h“
assert.equal(status(d, '2026-10-05', '14:00').text, 'Jetzt geöffnet · schließt um 14 Uhr')
assert.equal(status(d, '2026-10-06', '00:00').text, 'Jetzt geöffnet · schließt um 14 Uhr')
})
test('Schluss um Mitternacht mehr als 24 h entfernt: der Tag ist der, der endet („bis Dienstag Mitternacht“)', () => {
const d = daten({ plaene: [plan({ 1: ['10:00', '24:00'], 2: 'rund' })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Jetzt geöffnet · bis Dienstag Mitternacht')
})
test('Schluss mehr als 6 Tage entfernt: Datum statt Wochentag', () => {
// alles rund, aber am Mo 12.10. nur bis 09:00, danach Urlaub: Schluss in 6 Tagen 23 Stunden
const d = daten({
plaene: [plan(WOCHE_ALLE_RUND)],
ausnahmen: [sondertag({ id: 's', von: '2026-10-12', bis: '2026-10-12', fenster: [['00:00', '09:00']] }), urlaub({ von: '2026-10-13', bis: '2026-10-31' })],
})
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Jetzt geöffnet · bis 12.10. um 9 Uhr', kurz: 'Geöffnet' })
// Mitternacht: der endende Tag, nicht der Folgetag (Schluss Di 13.10. 00:00 = Montag Mitternacht)
const e = daten({ plaene: [plan(WOCHE_ALLE_RUND)], ausnahmen: [urlaub({ von: '2026-10-13', bis: '2026-10-31' })] })
assert.equal(status(e, '2026-10-06', '10:00').text, 'Jetzt geöffnet · bis Montag Mitternacht')
assert.equal(statusZu(e, hauptplan(e), JETZT).text, 'Rund um die Uhr geöffnet', 'ab Mo 10:00 sind es 7 Tage 14 Stunden')
})
test('7 Tage durchgehend: „Rund um die Uhr geöffnet“', () => {
const d = daten({ plaene: [plan(WOCHE_ALLE_RUND)] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: true, text: 'Rund um die Uhr geöffnet', kurz: 'Geöffnet' })
// dasselbe als Fenster 00:00–24:00 an jedem Tag (grenzende Fenster werden zusammengefügt)
const e = daten({ plaene: [plan(Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((t) => [t, ['00:00', '24:00']])))] })
assert.equal(statusZu(e, hauptplan(e), JETZT).text, 'Rund um die Uhr geöffnet')
// genau 7 Tage ab jetzt (Mo 12.10. 10:00 Ende) zählt noch, eine Minute später nicht mehr
const f = daten({
plaene: [plan(WOCHE_ALLE_RUND)],
ausnahmen: [sondertag({ id: 's', von: '2026-10-12', bis: '2026-10-12', fenster: [['00:00', '10:00']] }), urlaub({ von: '2026-10-13', bis: '2026-10-31' })],
})
assert.equal(statusZu(f, hauptplan(f), JETZT).text, 'Rund um die Uhr geöffnet')
assert.equal(status(f, '2026-10-05', '10:01').text, 'Jetzt geöffnet · bis 12.10. um 10 Uhr')
})
test('6 Tage durchgehend sind nicht „rund um die Uhr“', () => {
const d = daten({ plaene: [plan({ 1: 'rund', 2: 'rund', 3: 'rund', 4: 'rund', 5: 'rund', 6: 'rund' })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Jetzt geöffnet · bis Samstag Mitternacht')
})
test('zu: öffnet heute / morgen / Wochentag / Datum', () => {
const zu = (woche, tag = '2026-10-05', uhr = '10:00') => nurStatus(status(daten({ plaene: [plan(woche)] }), tag, uhr))
assert.deepEqual(zu({ 1: ['14:00', '18:00'] }), { offen: false, text: 'Geschlossen · öffnet heute um 14 Uhr', kurz: 'Geschlossen' })
assert.equal(zu({ 2: ['09:00', '18:00'] }).text, 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(zu({ 5: ['09:30', '18:00'] }).text, 'Geschlossen · öffnet Freitag um 9:30 Uhr')
assert.equal(zu({ 2: ['09:00', '18:00'] }, '2026-10-05', '20:00').text, 'Geschlossen · öffnet morgen um 9 Uhr')
// ≤ 6 Tage: Wochentag (So 11.10. = 6 Tage; Mo nach Di = 6 Tage)
assert.equal(zu({ 7: ['09:00', '18:00'] }).text, 'Geschlossen · öffnet Sonntag um 9 Uhr')
assert.equal(zu({ 1: ['09:00', '18:00'] }, '2026-10-06').text, 'Geschlossen · öffnet Montag um 9 Uhr')
// 7 Tage und mehr: Datum
assert.equal(zu({ 1: ['09:00', '18:00'] }, '2026-10-05', '19:00').text, 'Geschlossen · öffnet am 12.10. um 9 Uhr')
assert.equal(zu({ 1: ['09:00', '18:00'] }, '2026-10-05', '09:00').offen, true)
})
test('zu: Öffnung genau um Mitternacht heißt „0 Uhr“ (nicht „Mitternacht“, das wäre Tagesende)', () => {
const d = daten({ plaene: [plan({ 2: 'rund' })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Geschlossen · öffnet morgen um 0 Uhr')
const e = daten({ plaene: [plan({ 2: ['00:00', '06:00'] })] })
assert.equal(statusZu(e, hauptplan(e), JETZT).text, 'Geschlossen · öffnet morgen um 0 Uhr')
})
test('zu: nächste Öffnung nach Feiertag oder Urlaub wird gefunden, auch weit voraus', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })], ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-11-16' })] })
// Mo 05.10. 19:00: nächstes Montag 12.10. und 19.10. … alles Urlaub bis 16.11. -> Mo 23.11. = 49 Tage
assert.equal(status(d, '2026-10-05', '19:00').text, 'Geschlossen · öffnet am 23.11. um 9 Uhr')
})
test('keine Öffnung in 60 Tagen: „Derzeit geschlossen“', () => {
const d = daten({ plaene: [plan({})] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: false, text: 'Derzeit geschlossen', kurz: 'Geschlossen' })
// Sondertag genau am 60. Tag (04.12.) wird noch gefunden, am 61. (05.12.) nicht
const am60 = daten({ plaene: [plan({})], ausnahmen: [sondertag({ von: '2026-12-04', bis: '2026-12-04', fenster: [['09:00', '12:00']] })] })
assert.equal(statusZu(am60, hauptplan(am60), JETZT).text, 'Geschlossen · öffnet am 04.12. um 9 Uhr')
const am61 = daten({ plaene: [plan({})], ausnahmen: [sondertag({ von: '2026-12-05', bis: '2026-12-05', fenster: [['09:00', '12:00']] })] })
assert.equal(statusZu(am61, hauptplan(am61), JETZT).text, 'Derzeit geschlossen')
// kein Plan: auch kein Fehler
assert.equal(statusZu({}, null, JETZT).text, 'Derzeit geschlossen')
})
test('Grund an geschlossenen Tagen: Betriebsurlaub bis …', () => {
const p = plan(WOCHE_ALLE_9_18)
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-17', text: 'Sommerpause' })] })
assert.deepEqual(statusZu(d, p, JETZT), { offen: false, text: 'Heute geschlossen (Betriebsurlaub bis 17.10.)', kurz: 'Geschlossen' })
// aneinander grenzende Urlaube zählen zusammen
const zwei = daten({ plaene: [p], ausnahmen: [urlaub({ id: 'u1', von: '2026-10-05', bis: '2026-10-10' }), urlaub({ id: 'u2', von: '2026-10-11', bis: '2026-10-17' })] })
assert.equal(statusZu(zwei, p, JETZT).text, 'Heute geschlossen (Betriebsurlaub bis 17.10.)')
// ein einzelner Urlaubstag
const eins = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-05' })] })
assert.equal(statusZu(eins, p, JETZT).text, 'Heute geschlossen (Betriebsurlaub bis 05.10.)')
// ein geöffneter Sondertag mitten im Urlaub beendet die Kette
const luecke = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-17' }), sondertag({ von: '2026-10-09', bis: '2026-10-09' })] })
assert.equal(statusZu(luecke, p, JETZT).text, 'Heute geschlossen (Betriebsurlaub bis 08.10.)')
// Urlaub eines anderen Plans zählt nicht
const fremd = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-05', bis: '2026-10-17', plan: 'anderer' })] })
assert.equal(statusZu(fremd, p, JETZT).text, 'Jetzt geöffnet · schließt um 18 Uhr')
// Urlaub ab morgen: heute normal
const morgen = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-06', bis: '2026-10-17' })] })
assert.equal(statusZu(morgen, p, JETZT).text, 'Jetzt geöffnet · schließt um 18 Uhr')
assert.equal(status(morgen, '2026-10-05', '19:00').text, 'Geschlossen · öffnet am 18.10. um 9 Uhr')
})
test('Grund an geschlossenen Tagen: Feiertag mit Namen (auch geschlossener Prüftag)', () => {
const p = plan(WOCHE_ALLE_9_18)
const d = daten({ plaene: [p] })
assert.deepEqual(status(d, '2026-10-03', '10:00'), { offen: false, text: 'Heute geschlossen (Feiertag: Tag der Deutschen Einheit)', kurz: 'Geschlossen' })
// Prüftag (R5): vom Kunden geschlossen ⇒ derselbe Grundtext
const heiligabend = daten({ plaene: [p], feiertage: { '2026-12-24': { modus: 'zu', fenster: [] } } })
assert.equal(status(heiligabend, '2026-12-24', '10:00').text, 'Heute geschlossen (Feiertag: Heiligabend)')
// Prüftag ohne Abweichung = normaler Wochentag
assert.equal(status(d, '2026-12-24', '10:00').text, 'Jetzt geöffnet · schließt um 18 Uhr')
// Feiertag mit Öffnungszeit (Abweichung): kein Grund, nach Schluss normaler Text
const offen = daten({ plaene: [p], feiertage: { '2026-10-03': { modus: 'zeiten', fenster: [['10:00', '14:00']] } } })
assert.equal(status(offen, '2026-10-03', '10:30').text, 'Jetzt geöffnet · schließt um 14 Uhr')
assert.equal(status(offen, '2026-10-03', '15:00').text, 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(status(offen, '2026-10-03', '09:00').text, 'Geschlossen · öffnet heute um 10 Uhr')
})
test('Grund: regulär geschlossene Tage und geschlossene Sondertage haben keinen Grundtext', () => {
const d = daten({ plaene: [plan({ 2: ['09:00', '18:00'] })] })
assert.equal(statusZu(d, hauptplan(d), JETZT).text, 'Geschlossen · öffnet morgen um 9 Uhr')
const s = daten({ plaene: [plan(WOCHE_ALLE_9_18)], ausnahmen: [sondertag({ von: '2026-10-05', bis: '2026-10-05', text: 'Inventur', modus: 'zu', fenster: [] })] })
assert.equal(statusZu(s, hauptplan(s), JETZT).text, 'Geschlossen · öffnet morgen um 9 Uhr')
})
test('Urlaub: ein vom Vortag hineinlaufendes Fenster hält offen, danach „Heute geschlossen“', () => {
// Bar: Freitag 18–02, Samstag ist Betriebsurlaub
const d = daten({ plaene: [plan({ 5: ['18:00', '02:00'], 6: ['18:00', '02:00'] })], ausnahmen: [urlaub({ von: '2026-10-10', bis: '2026-10-11' })] })
assert.deepEqual(status(d, '2026-10-10', '01:00'), { offen: true, text: 'Jetzt geöffnet · schließt um 2 Uhr', kurz: 'Geöffnet' })
assert.equal(status(d, '2026-10-10', '03:00').text, 'Heute geschlossen (Betriebsurlaub bis 11.10.)')
})
test('nach Vereinbarung: „Heute nach Vereinbarung“ mit offen: null', () => {
const d = daten({ plaene: [plan({ 1: 'vereinbarung', 2: ['09:00', '18:00'] })] })
assert.deepEqual(statusZu(d, hauptplan(d), JETZT), { offen: null, text: 'Heute nach Vereinbarung', kurz: 'Nach Vereinbarung' })
// Vereinbarung an einem Feiertag (Abweichung)
const f = daten({ plaene: [plan(WOCHE_ALLE_9_18)], feiertage: { '2026-10-03': { modus: 'vereinbarung', fenster: [] } } })
assert.deepEqual(status(f, '2026-10-03', '10:00'), { offen: null, text: 'Heute nach Vereinbarung', kurz: 'Nach Vereinbarung' })
// ein vom Vortag hineinlaufendes Fenster ist weiter „offen“, danach Vereinbarung
const n = daten({ plaene: [plan({ 7: ['20:00', '02:00'], 1: 'vereinbarung' })] })
assert.equal(status(n, '2026-10-05', '01:00').offen, true)
assert.equal(status(n, '2026-10-05', '02:00').offen, null)
// Vereinbarung erst morgen ändert heute nichts
const m = daten({ plaene: [plan({ 2: 'vereinbarung', 3: ['09:00', '18:00'] })] })
assert.deepEqual(nurStatus(statusZu(m, hauptplan(m), JETZT)), { offen: false, text: 'Geschlossen · öffnet Mittwoch um 9 Uhr', kurz: 'Geschlossen' })
})
test('Fenster 18–02: um 01:00 am Folgetag offen, ab 02:00 zu', () => {
const d = daten({ plaene: [plan({ 6: ['18:00', '02:00'] })] })
assert.deepEqual(status(d, '2026-10-11', '01:00'), { offen: true, text: 'Jetzt geöffnet · schließt um 2 Uhr', kurz: 'Geöffnet' })
assert.equal(status(d, '2026-10-11', '01:59').offen, true)
assert.equal(status(d, '2026-10-11', '02:00').offen, false)
assert.equal(status(d, '2026-10-10', '20:00').text, 'Jetzt geöffnet · schließt um 2 Uhr')
// Bar: Samstag 18–02, Sonntag ist Betriebsurlaub ⇒ bis 02:00 trotzdem offen
const f = daten({ plaene: [plan({ 6: ['18:00', '02:00'] })], ausnahmen: [urlaub({ von: '2026-10-11', bis: '2026-10-11' })] })
assert.equal(status(f, '2026-10-11', '01:30').offen, true)
})
test('Sommerzeit 25.10.: Fenster 02:00–06:00 ⇒ „schließt um 6 Uhr“ (beide 02:30-Zeitpunkte)', () => {
const d = daten({ plaene: [plan({ 7: ['02:00', '06:00'] })] })
// 02:30 MESZ (erstes Vorkommen) und 02:30 MEZ (zweites Vorkommen)
assert.deepEqual(statusZu(d, hauptplan(d), new Date('2026-10-25T00:30:00Z')), { offen: true, text: 'Jetzt geöffnet · schließt um 6 Uhr', kurz: 'Geöffnet' })
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-25T01:30:00Z')).text, 'Jetzt geöffnet · schließt um 6 Uhr')
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-25T04:59:00Z')).offen, true)
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-25T05:00:00Z')).offen, false)
// eine Minute vor Beginn (01:59 MESZ)
assert.equal(statusZu(d, hauptplan(d), new Date('2026-10-24T23:59:00Z')).text, 'Geschlossen · öffnet heute um 2 Uhr')
})
test('Sommerzeit 29.03.: Fenster 02:00–06:00 beginnt in der Lücke um 03:00', () => {
const d = daten({ plaene: [plan({ 7: ['02:00', '06:00'] })] })
assert.equal(status(d, '2026-03-28', '10:00').text, 'Geschlossen · öffnet morgen um 3 Uhr')
assert.equal(status(d, '2026-03-29', '03:30').text, 'Jetzt geöffnet · schließt um 6 Uhr')
})
test('nie „24 Uhr“ in irgendeinem Text (jede Viertelstunde einer Woche, rund/24h-Pläne)', () => {
const plaene = [
plan(WOCHE_ALLE_RUND),
plan(Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((t) => [t, ['00:00', '24:00']]))),
plan({ 1: ['18:00', '24:00'], 2: ['00:00', '02:00'], 3: ['22:00', '00:00'], 5: 'rund', 6: 'rund', 7: ['06:00', '24:00'] }),
plan({ 1: ['00:00', '24:00'], 2: ['10:00', '24:00'], 3: ['00:00', '12:00'], 5: [['00:00', '08:00'], ['20:00', '24:00']] }),
]
const start = zuUtc('2026-10-05', '00:00').getTime()
const KONTROLLE = /24 Uhr/
for (const p of plaene) {
const d = daten({ plaene: [p] })
for (let k = 0; k < 7 * 96; k += 1) {
const jetzt = new Date(start + k * 15 * 60 * 1000)
const s = statusZu(d, p, jetzt)
assert.doesNotMatch(s.text, KONTROLLE, `${berlin(jetzt.getTime())}: ${s.text}`)
}
for (const w of wechselListe(d, p, new Date(start))) assert.doesNotMatch(w.text, KONTROLLE, w.text)
}
})
test('statusZu verändert die Daten nicht und prüft den Zeitpunkt', () => {
const d = tiefEinfrieren(daten({ plaene: [plan({ 1: ['09:00', '18:00'], 6: ['22:00', '02:00'] })], ausnahmen: [urlaub({ von: '2026-10-12', bis: '2026-10-14' })] }))
assert.equal(statusZu(d, hauptplan(d), JETZT).offen, true)
assert.equal(wechselListe(d, hauptplan(d), JETZT).length > 3, true)
assert.equal(tageListe(d, hauptplan(d), '2026-10-05', 3).length, 3)
for (const falsch of [undefined, null, '2026-10-05', 1759651200000, new Date('x')]) {
assert.throws(() => statusZu(d, hauptplan(d), falsch), RangeError, String(falsch))
assert.throws(() => wechselListe(d, hauptplan(d), falsch), RangeError, String(falsch))
}
})
// ---------------------------------------------------------------- Wechselliste
const wl = (d, now = JETZT) => wechselListe(d, hauptplan(d), now)
const kompakt = (liste) => liste.map((w) => [berlin(w.ab), w.text])
test('wechselListe: erster Eintrag ist jetzt (Beginn der Minute), danach Wechsel um 18:00', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
const mitSekunden = new Date(JETZT.getTime() + 30500)
const liste = wl(d, mitSekunden)
assert.equal(liste[0].ab, JETZT.getTime())
assert.deepEqual(nurStatus(liste[0]), { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' })
assert.equal(liste[1].ab, zuUtc('2026-10-05', '18:00').getTime())
assert.deepEqual(nurStatus(liste[1]), { offen: false, text: 'Geschlossen · öffnet am 12.10. um 9 Uhr', kurz: 'Geschlossen' })
assert.deepEqual(Object.keys(liste[0]), ['ab', 'tag', 'offen', 'text', 'kurz'])
assert.ok(liste.every((w) => Number.isInteger(w.ab)))
})
test('wechselListe: vollständige Liste für Mo 09–18, 8 Tage, nur Wechsel und Texttage', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
assert.deepEqual(kompakt(wl(d)), [
['2026-10-05 10:00', 'Jetzt geöffnet · schließt um 18 Uhr'],
['2026-10-05 18:00', 'Geschlossen · öffnet am 12.10. um 9 Uhr'],
['2026-10-06 00:00', 'Geschlossen · öffnet Montag um 9 Uhr'],
['2026-10-11 00:00', 'Geschlossen · öffnet morgen um 9 Uhr'],
['2026-10-12 00:00', 'Geschlossen · öffnet heute um 9 Uhr'],
['2026-10-12 09:00', 'Jetzt geöffnet · schließt um 18 Uhr'],
['2026-10-12 18:00', 'Geschlossen · öffnet am 19.10. um 9 Uhr'],
['2026-10-13 00:00', 'Geschlossen · öffnet Montag um 9 Uhr'],
])
})
test('wechselListe: Mitternachtseintrag wechselt „morgen“ zu „heute“', () => {
const d = daten({ plaene: [plan({ 2: ['09:00', '18:00'] })] })
const liste = wl(d)
assert.equal(liste[0].text, 'Geschlossen · öffnet morgen um 9 Uhr')
assert.equal(liste[1].ab, zuUtc('2026-10-06', '00:00').getTime())
assert.equal(liste[1].text, 'Geschlossen · öffnet heute um 9 Uhr')
assert.equal(liste[1].offen, false)
assert.equal(liste[2].ab, zuUtc('2026-10-06', '09:00').getTime())
assert.equal(liste[2].offen, true)
})
test('wechselListe hat tag (Berliner Datum bei ab)', () => {
const d = daten({ plaene: [plan({ 6: ['22:00', '02:00'] })] })
const liste = wl(d, new Date('2026-10-10T20:30:00Z')) // Sa 22:30 Berlin, offen bis So 02:00
assert.equal(liste[0].tag, '2026-10-10')
assert.deepEqual(nurStatus(liste[0]), { offen: true, text: 'Jetzt geöffnet · schließt um 2 Uhr', kurz: 'Geöffnet' })
const zu = liste.find((w) => w.offen === false)
assert.equal(berlin(zu.ab), '2026-10-11 02:00')
assert.equal(zu.tag, '2026-10-11')
for (const w of liste) assert.equal(w.tag, ausUtc(new Date(w.ab)).tag)
// knapp vor Mitternacht: tag wechselt mit dem Mitternachtseintrag
const spaet = wl(daten({ plaene: [plan({})] }), new Date('2026-10-05T21:59:30Z')) // Mo 23:59 Berlin
assert.equal(spaet[0].tag, '2026-10-05')
})
test('wechselListe: strikt aufsteigend, jeder Eintrag ändert etwas, innerhalb von 8 Tagen', () => {
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: 'vereinbarung', 7: ['10:00', '14:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-08', bis: '2026-10-09' })] })
const liste = wl(d)
assert.ok(liste.length > 10)
for (let k = 1; k < liste.length; k += 1) {
assert.ok(liste[k].ab > liste[k - 1].ab)
const a = liste[k - 1]
const b = liste[k]
assert.ok(a.offen !== b.offen || a.text !== b.text || a.kurz !== b.kurz, `Eintrag ${k} ändert nichts`)
}
const ende = zuUtc('2026-10-13', '10:00').getTime()
assert.ok(liste[liste.length - 1].ab < ende)
assert.ok(liste.every((w) => w.ab >= JETZT.getTime()))
})
test('wechselListe stimmt mit statusZu überein (offen und kurz zu jeder Viertelstunde, Text bei kurzen Läufen)', () => {
const p = plan({ 1: [['09:00', '12:00'], ['13:00', '22:00']], 2: ['09:00', '12:00'], 4: ['20:00', '03:00'], 6: 'vereinbarung', 7: ['10:00', '14:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-08', bis: '2026-10-09' })] })
const liste = wl(d)
const ende = zuUtc('2026-10-13', '10:00').getTime()
for (let ms = JETZT.getTime(); ms < ende; ms += 15 * 60 * 1000) {
let w = liste[0]
for (const e of liste) if (e.ab <= ms) w = e
const s = statusZu(d, p, new Date(ms))
assert.deepEqual(nurStatus(w), nurStatus(s), berlin(ms))
}
})
test('wechselListe: Mitternachten an der Zeitumstellung sind Berliner 00:00 (25.10. und 29.03.)', () => {
const d = daten({ plaene: [plan({ 1: ['09:00', '18:00'] })] })
const okt = wl(d, new Date('2026-10-23T08:00:00Z')).map((w) => w.ab)
assert.ok(okt.includes(Date.parse('2026-10-24T22:00:00Z')), '25.10. 00:00 MESZ')
assert.ok(okt.includes(Date.parse('2026-10-25T23:00:00Z')), '26.10. 00:00 MEZ')
const maerz = wl(d, new Date('2026-03-27T08:00:00Z')).map((w) => w.ab)
assert.ok(maerz.includes(Date.parse('2026-03-28T23:00:00Z')), '29.03. 00:00 MEZ')
assert.ok(maerz.includes(Date.parse('2026-03-29T22:00:00Z')), '30.03. 00:00 MESZ')
// 8 Tage über die Umstellung: Wanduhr-Ende, nicht 8 × 24 h
const spaetEintrag = wl(d, new Date('2026-10-23T08:00:00Z'))
assert.ok(spaetEintrag[spaetEintrag.length - 1].ab < Date.parse('2026-10-31T09:00:00Z'))
})
test('wechselListe: Plan rund um die Uhr hat genau einen Eintrag', () => {
const d = daten({ plaene: [plan(WOCHE_ALLE_RUND)] })
const liste = wl(d)
assert.equal(liste.length, 1)
assert.deepEqual(nurStatus(liste[0]), { offen: true, text: 'Rund um die Uhr geöffnet', kurz: 'Geöffnet' })
})
test('wechselListe: Urlaub, Feiertag und Vereinbarung erscheinen mit Grund, wenn der Text wechselt', () => {
const p = plan({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: 'vereinbarung', 4: ['09:00', '18:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-08', bis: '2026-10-09' })] })
const texte = wl(d).map((w) => w.text)
assert.ok(texte.includes('Heute nach Vereinbarung'))
assert.ok(texte.includes('Heute geschlossen (Betriebsurlaub bis 09.10.)'))
})
// ---------------------------------------------------------------- tageListe
test('tageListe liefert datum/art/name/modus/fenster für n Tage ab vonTag', () => {
const p = plan({ 1: ['09:00', '18:00'], 6: ['10:00', '14:00'] })
const d = daten({ plaene: [p], ausnahmen: [urlaub({ von: '2026-10-06', bis: '2026-10-06', text: 'Brückentag' })] })
const liste = tageListe(d, p, '2026-10-02', 5)
assert.equal(liste.length, 5)
assert.deepEqual(liste.map((t) => t.datum), ['2026-10-02', '2026-10-03', '2026-10-04', '2026-10-05', '2026-10-06'])
assert.deepEqual(Object.keys(liste[0]), ['datum', 'art', 'name', 'modus', 'fenster'])
assert.deepEqual(liste[1], { datum: '2026-10-03', art: 'feiertag', name: 'Tag der Deutschen Einheit', modus: 'zu', fenster: [] })
assert.deepEqual(liste[3], { datum: '2026-10-05', art: 'regulaer', name: null, modus: 'zeiten', fenster: [['09:00', '18:00']] })
assert.deepEqual(liste[4], { datum: '2026-10-06', art: 'urlaub', name: 'Brückentag', modus: 'zu', fenster: [] })
// dieselben Werte wie tagesbild, nur heißt der Tag hier „datum“
for (const t of liste) {
const { tag, ...b } = tagesbild(d, p, t.datum)
const { datum, ...rest } = t
assert.equal(datum, tag)
assert.deepEqual(rest, b)
}
})
test('tageListe: 60 Tage über den Jahreswechsel, Randfälle und Fehler', () => {
const p = plan({ 1: ['09:00', '18:00'] })
const d = daten({ plaene: [p] })
const liste = tageListe(d, p, '2026-12-01', 60)
assert.equal(liste.length, 60)
assert.equal(liste[0].datum, '2026-12-01')
assert.equal(liste[59].datum, '2027-01-29')
assert.equal(new Set(liste.map((t) => t.datum)).size, 60)
assert.deepEqual(tageListe(d, p, '2026-12-01', 0), [])
assert.throws(() => tageListe(d, p, '2026-02-30', 3), RangeError)
assert.throws(() => tageListe(d, p, undefined, 3), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', -1), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', 1.5), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', '5'), RangeError)
assert.throws(() => tageListe(d, p, '2026-12-01', 1000), RangeError)
// Kopie: Änderungen an den Fenstern der Liste verändern die Daten nicht
const l2 = tageListe(d, p, '2026-10-05', 1)
l2[0].fenster.push(['20:00', '21:00'])
assert.deepEqual(p.tage[1].fenster, [['09:00', '18:00']])
})
// ---------------------------------------------------------------- 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')
})