Ö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>
This commit is contained in:
1 parent
2636ce60be
commit
0f25f7cb52
2 files changed
+828
No files matched your search
@@ -0,0 +1,236 @@
|
||||
import { zuUtc, wochentag, plusTage } from '../termine/zeit.js'
|
||||
import { BUNDESLAENDER, feiertageImZeitraum } from '../termine/feiertage.js'
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live: Zeitlogik (rein, Europe/Berlin).
|
||||
* Kein Appwrite, keine Config, kein eigener Zeitgeber: Aufrufer geben Daten und 'now' hinein.
|
||||
* Alles rechnet in Berliner Wanduhrzeit ueber termine/zeit.js; Kalendertage nur mit plusTage, nie als Vielfache von Tages-Millisekunden.
|
||||
*
|
||||
* Datenform (Spec 2.1): daten = { land, plaene: [{ id, kurz, name, haupt, tage: { 1..7: { modus, fenster } } }],
|
||||
* feiertage: { 'YYYY-MM-DD': { modus, fenster } }, ausnahmen: [{ id, art: 'urlaub'|'sondertag', von, bis, text,
|
||||
* modus, fenster, plan: null|planId }], ... }. modus: 'zu' | 'zeiten' | 'rund' | 'vereinbarung';
|
||||
* fenster: [['09:00','12:30'], ...] (nur bei 'zeiten'; 'bis' vor 'von' = bis zum Folgetag).
|
||||
*
|
||||
* Alle Funktionen sind tolerant gegenueber kaputten gespeicherten Daten (unbekannter Modus, fehlende Tage,
|
||||
* unlesbare Fenster => zu), werfen aber bei einem ungueltigen Tag-Argument (Programmierfehler).
|
||||
*/
|
||||
|
||||
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const ZEIT_RE = /^\d{2}:\d{2}$/
|
||||
const MODI = Object.freeze(['zu', 'zeiten', 'rund', 'vereinbarung'])
|
||||
const STANDARD_LAND = 'RP'
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
/** Ist t ein echter Kalendertag 'YYYY-MM-DD' (kein 30.02.)? */
|
||||
function gueltigerTag(t) {
|
||||
return typeof t === 'string' && TAG_RE.test(t) && plusTage(t, 0) === t
|
||||
}
|
||||
|
||||
function pruefeTag(t, name) {
|
||||
if (!gueltigerTag(t)) throw new RangeError(`${name}: ungueltiger Tag ${String(t)}`)
|
||||
}
|
||||
|
||||
/** 'HH:MM' (auch '24:00') -> Minuten seit 00:00, sonst null. */
|
||||
function minuten(hhmm) {
|
||||
if (typeof hhmm !== 'string' || !ZEIT_RE.test(hhmm)) return null
|
||||
const h = Number(hhmm.slice(0, 2))
|
||||
const m = Number(hhmm.slice(3, 5))
|
||||
if (m > 59 || h > 24 || (h === 24 && m !== 0)) return null
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
const textOderNull = (t) => (typeof t === 'string' && t.trim() !== '' ? t : null)
|
||||
|
||||
// ------------------------------------------------------------------ Plaene
|
||||
|
||||
/** Der Hauptplan (haupt: true) oder null. */
|
||||
export function hauptplan(daten) {
|
||||
const plaene = daten?.plaene
|
||||
if (!Array.isArray(plaene)) return null
|
||||
return plaene.find((p) => p && p.haupt === true) ?? null
|
||||
}
|
||||
|
||||
/** Der Plan mit diesem Kurznamen ([a-z0-9-], exakter Vergleich) oder null. */
|
||||
export function planNachKurz(daten, kurz) {
|
||||
const plaene = daten?.plaene
|
||||
if (!Array.isArray(plaene) || typeof kurz !== 'string' || kurz === '') return null
|
||||
return plaene.find((p) => p && p.kurz === kurz) ?? null
|
||||
}
|
||||
|
||||
/** Bundesland der Daten; fehlend oder unbekannt = Vorgabe RP (Spec 2.1). */
|
||||
export function landVon(daten) {
|
||||
return BUNDESLAENDER.includes(daten?.land) ? daten.land : STANDARD_LAND
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Wanduhr -> UTC
|
||||
|
||||
/**
|
||||
* Berliner Wanduhr -> UTC-Zeitpunkt wie zuUtc, aber ohne null: Faellt die Zeit in die Luecke der
|
||||
* Maerz-Umstellung (02:00-02:59), gilt die erste existierende Minute danach (03:00). Doppelte Zeiten
|
||||
* (Oktober) ergeben das erste Vorkommen, '24:00' den Folgetag 00:00. Ungueltige Eingabe wirft RangeError.
|
||||
*/
|
||||
export function zuUtcOderDanach(tag, hhmm) {
|
||||
const direkt = zuUtc(tag, hhmm)
|
||||
if (direkt) return direkt
|
||||
const min = minuten(hhmm)
|
||||
if (!gueltigerTag(tag) || min === null) throw new RangeError(`ungueltige Wanduhrzeit ${String(tag)} ${String(hhmm)}`)
|
||||
for (let m = min + 1; m <= 1440; m += 1) {
|
||||
const kandidat = zuUtc(tag, `${pad(Math.floor(m / 60))}:${pad(m % 60)}`)
|
||||
if (kandidat) return kandidat
|
||||
}
|
||||
throw new RangeError(`keine existierende Wanduhrzeit nach ${tag} ${hhmm}`)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Tagesbild
|
||||
|
||||
/** Gespeicherte Fenster -> nur brauchbare, nach Beginn sortiert, als Kopie ('24:00' nur als Ende, von != bis). */
|
||||
function sauberFenster(roh) {
|
||||
if (!Array.isArray(roh)) return []
|
||||
const fenster = []
|
||||
for (const e of roh) {
|
||||
if (!Array.isArray(e) || e.length !== 2) continue
|
||||
const von = minuten(e[0])
|
||||
const bis = minuten(e[1])
|
||||
if (von === null || bis === null || von >= 1440 || von === bis) continue
|
||||
fenster.push([e[0], e[1]])
|
||||
}
|
||||
return fenster.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
}
|
||||
|
||||
/** { modus, fenster } eines gespeicherten Tageseintrags: unbekannter Modus oder 'zeiten' ohne Fenster = zu. */
|
||||
function modusUndFenster(eintrag) {
|
||||
const modus = MODI.includes(eintrag?.modus) ? eintrag.modus : 'zu'
|
||||
if (modus !== 'zeiten') return { modus, fenster: [] }
|
||||
const fenster = sauberFenster(eintrag.fenster)
|
||||
return fenster.length ? { modus, fenster } : { modus: 'zu', fenster: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Die geltende Ausnahme dieser Art fuer Plan und Tag: planbezogene schlagen globale (plan null),
|
||||
* bei Gleichstand gewinnt die spaeter angelegte (hoeherer Index). Ausnahmen mit unlesbarem Zeitraum,
|
||||
* von > bis oder fuer einen anderen Plan zaehlen nicht; fehlendes 'bis' = ein Tag.
|
||||
*/
|
||||
function ausnahmeFuer(daten, planId, tag, art) {
|
||||
const liste = Array.isArray(daten?.ausnahmen) ? daten.ausnahmen : []
|
||||
let treffer = null
|
||||
let trefferRang = -1
|
||||
for (const a of liste) {
|
||||
if (!a || a.art !== art) continue
|
||||
const von = a.von
|
||||
const bis = a.bis ?? a.von
|
||||
if (typeof von !== 'string' || typeof bis !== 'string' || !TAG_RE.test(von) || !TAG_RE.test(bis)) continue
|
||||
if (tag < von || tag > bis) continue
|
||||
const global = a.plan === null || a.plan === undefined
|
||||
if (!global && a.plan !== planId) continue
|
||||
const rang = global ? 0 : 1
|
||||
// Liste in Anlegereihenfolge: bei gleichem Rang ersetzt der spaetere (>=)
|
||||
if (rang >= trefferRang) {
|
||||
treffer = a
|
||||
trefferRang = rang
|
||||
}
|
||||
}
|
||||
return treffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Feiertage je (Jahr, Land), einmal berechnet: Map tag -> { name, art: 'feiertag'|'pruefen' }.
|
||||
* Begrenzt, damit beliebige Jahre den Speicher nicht fuellen.
|
||||
*/
|
||||
const FEIERTAGE_CACHE = new Map()
|
||||
function feiertageDesJahres(jahr, land) {
|
||||
const schluessel = `${jahr}|${land}`
|
||||
let karte = FEIERTAGE_CACHE.get(schluessel)
|
||||
if (!karte) {
|
||||
karte = new Map()
|
||||
for (const e of feiertageImZeitraum(`${jahr}-01-01`, `${jahr}-12-31`, land)) {
|
||||
karte.set(e.tag, Object.freeze({ name: e.name, art: e.art }))
|
||||
}
|
||||
if (FEIERTAGE_CACHE.size >= 200) FEIERTAGE_CACHE.clear()
|
||||
FEIERTAGE_CACHE.set(schluessel, karte)
|
||||
}
|
||||
return karte
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Tagesbild eines Plans an einem Berliner Kalendertag (Spec 6, spezifisch vor allgemein):
|
||||
* Sondertag > Betriebsurlaub > Feiertag (Abweichung oder Vorgabe) > Wochenplan.
|
||||
*
|
||||
* Rueckgabe { tag, art: 'regulaer'|'feiertag'|'urlaub'|'sondertag', name, modus, fenster }:
|
||||
* - name: Feiertagsname, bei Urlaub/Sondertag der Text der Ausnahme, sonst null.
|
||||
* - Urlaub ist immer zu. Gesetzlicher Feiertag ohne Abweichung ist zu; ein „bitte pruefen“-Tag
|
||||
* (Heiligabend, Silvester, ...) folgt dem Wochenplan (art 'regulaer'), ausser der Kunde hat unter
|
||||
* daten.feiertage[tag] eine Abweichung gespeichert (dann art 'feiertag' mit dem Namen des Tages).
|
||||
* - fenster ist immer ein neues Array (bei 'zu', 'rund', 'vereinbarung' leer); 'bis' kann '24:00' sein.
|
||||
*/
|
||||
export function tagesbild(daten, plan, tag) {
|
||||
pruefeTag(tag, 'tag')
|
||||
const planId = plan?.id
|
||||
|
||||
const sonder = ausnahmeFuer(daten, planId, tag, 'sondertag')
|
||||
if (sonder) return { tag, art: 'sondertag', name: textOderNull(sonder.text), ...modusUndFenster(sonder) }
|
||||
|
||||
const urlaub = ausnahmeFuer(daten, planId, tag, 'urlaub')
|
||||
if (urlaub) return { tag, art: 'urlaub', name: textOderNull(urlaub.text), modus: 'zu', fenster: [] }
|
||||
|
||||
const feiertag = feiertageDesJahres(tag.slice(0, 4), landVon(daten)).get(tag)
|
||||
if (feiertag) {
|
||||
const abweichung = daten?.feiertage?.[tag]
|
||||
if (abweichung && MODI.includes(abweichung.modus)) {
|
||||
return { tag, art: 'feiertag', name: feiertag.name, ...modusUndFenster(abweichung) }
|
||||
}
|
||||
if (feiertag.art === 'feiertag') return { tag, art: 'feiertag', name: feiertag.name, modus: 'zu', fenster: [] }
|
||||
}
|
||||
|
||||
return { tag, art: 'regulaer', name: null, ...modusUndFenster(plan?.tage?.[wochentag(tag)]) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Oeffnungsintervalle
|
||||
|
||||
/**
|
||||
* Zusammengefuegte Oeffnungsintervalle [{ vonMs, bisMs }] (UTC-Millisekunden, sortiert) fuer die
|
||||
* Berliner Kalendertage vonTag bis bisTag (inklusive).
|
||||
* - Fenster gehoeren zum Starttag; 'bis' vor 'von' laeuft bis zum Folgetag und folgt der Regel des Starttags
|
||||
* (auch wenn der Folgetag Feiertag, Urlaub oder zu ist). Ein am letzten Tag beginnendes Fenster wird
|
||||
* bis zu seinem Ende geliefert.
|
||||
* - Enthalten ist auch das vom Vortag in vonTag hineinlaufende Fenster, ungekuerzt (es beginnt vor vonTag 00:00).
|
||||
* - 'rund' = 00:00 bis Folgetag 00:00; 'zu' und 'vereinbarung' ergeben nichts.
|
||||
* - Ueberlappende und aneinander grenzende Intervalle (auch ueber Tage) sind zusammengefuegt.
|
||||
* - Fenster in der Maerz-Luecke beginnen/enden um 03:00, Fenster ohne Dauer fallen weg.
|
||||
* vonTag nach bisTag => []. Ungueltige Tage werfen RangeError.
|
||||
*/
|
||||
export function oeffnungsIntervalle(daten, plan, vonTag, bisTag) {
|
||||
pruefeTag(vonTag, 'vonTag')
|
||||
pruefeTag(bisTag, 'bisTag')
|
||||
if (vonTag > bisTag) return []
|
||||
|
||||
const bereichStart = zuUtcOderDanach(vonTag, '00:00').getTime()
|
||||
const roh = []
|
||||
const nimm = (vonMs, bisMs) => {
|
||||
if (bisMs > vonMs && bisMs > bereichStart) roh.push({ vonMs, bisMs })
|
||||
}
|
||||
|
||||
for (let tag = plusTage(vonTag, -1); tag <= bisTag; tag = plusTage(tag, 1)) {
|
||||
const bild = tagesbild(daten, plan, tag)
|
||||
if (bild.modus === 'rund') {
|
||||
nimm(zuUtcOderDanach(tag, '00:00').getTime(), zuUtcOderDanach(tag, '24:00').getTime())
|
||||
} else if (bild.modus === 'zeiten') {
|
||||
for (const [von, bis] of bild.fenster) {
|
||||
const ueberMitternacht = bis <= von
|
||||
nimm(
|
||||
zuUtcOderDanach(tag, von).getTime(),
|
||||
zuUtcOderDanach(ueberMitternacht ? plusTage(tag, 1) : tag, bis).getTime(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
roh.sort((a, b) => a.vonMs - b.vonMs || a.bisMs - b.bisMs)
|
||||
const zusammen = []
|
||||
for (const i of roh) {
|
||||
const letztes = zusammen[zusammen.length - 1]
|
||||
if (letztes && i.vonMs <= letztes.bisMs) letztes.bisMs = Math.max(letztes.bisMs, i.bisMs)
|
||||
else zusammen.push({ vonMs: i.vonMs, bisMs: i.bisMs })
|
||||
}
|
||||
return zusammen
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
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')
|
||||
})
|
||||
Reference in new issue
Block a user