Files

565 lines
32 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { belegungAus, freieZeiten, freieTage, waehleRessource, blockEnde } from '../server/services/termine/slots.js'
import { zuUtc } from '../server/services/termine/zeit.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
// ---------------------------------------------------------------- Bausteine
const R1 = 'r_aaaaaaaa'
const R2 = 'r_bbbbbbbb'
const R3 = 'r_cccccccc'
const S = 's_aaaaaaaa'
const JETZT = new Date('2026-10-01T06:00:00Z') // Do 01.10.2026 08:00 Berlin
const MO = '2026-10-12' // Montag
const wochenplan = (fenster, tage = [1, 2, 3, 4, 5, 6, 7]) => Object.fromEntries(tage.map((t) => [t, fenster]))
const ressource = (id, plan, extra = {}) => ({ id, typ: 'person', name: id, aktiv: true, plan, ...extra })
const TAG = [['09:00', '17:00']]
function einstellung({
ressourcen = [ressource(R1, wochenplan(TAG))], dauer = 60, pufferNach = 0, raster = 15, vorlaufMin = 0, horizontTage = 365,
zuordnung = null, ...rest
} = {}) {
return {
bundesland: 'RP',
feiertageGeschlossen: true,
sonderoeffnungen: {},
abwesenheiten: [],
regeln: { raster, vorlaufMin, horizontTage },
ressourcen,
leistungen: [{ id: S, dauer, pufferNach, ressourcen: zuordnung || ressourcen.map((r) => r.id), aktiv: true }],
...rest,
}
}
const frei = (e, tag, extra = {}) => freieZeiten({ einstellung: e, leistungId: S, ressourceId: 'egal', tag, belegung: [], now: JETZT, ...extra })
const uhrzeiten = (liste) => liste.map((z) => z.uhrzeit)
const appwrite = (date) => date.toISOString().replace('Z', '+00:00') // Appwrite liefert +00:00
const buchung = (id, ressourceId, von, bis, status = 'bestaetigt', extra = {}) => ({
$id: id, ressourceId, status, startsAt: appwrite(von), blockEndsAt: appwrite(bis), ...extra,
})
const belegt = (ressourceId, von, bis) => ({ ressourceId, start: von, ende: bis })
const um = (tag, hhmm) => zuUtc(tag, hhmm)
// ---------------------------------------------------------------- belegungAus
test('belegungAus: nur belegende Status, Haltung nur solange heldUntil in der Zukunft liegt', () => {
const now = new Date('2026-10-12T08:00:00Z')
const von = new Date('2026-10-12T10:00:00Z')
const bis = new Date('2026-10-12T11:00:00Z')
const buchungen = [
buchung('b1', R1, von, bis, 'bestaetigt'),
buchung('b2', R1, von, bis, 'angefragt'),
buchung('b3', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T08:00:01Z')) }),
buchung('b4', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T08:00:00Z')) }), // genau jetzt = abgelaufen
buchung('b5', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T07:00:00Z')) }),
buchung('b6', R1, von, bis, 'gehalten'), // ohne heldUntil
...['erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen', 'unbekannt'].map((s, i) => buchung(`x${i}`, R1, von, bis, s)),
]
const ergebnis = belegungAus(buchungen, now)
assert.equal(ergebnis.length, 3)
assert.deepEqual(ergebnis[0], { ressourceId: R1, start: von, ende: bis })
assert.ok(ergebnis.every((b) => b.start instanceof Date && b.ende instanceof Date))
})
test('belegungAus: ende ist blockEndsAt (mit Puffer), +00:00-Schreibweise wird gelesen', () => {
const [eintrag] = belegungAus([{ $id: 'b', ressourceId: R2, status: 'bestaetigt', startsAt: '2026-10-12T08:00:00.000+00:00', blockEndsAt: '2026-10-12T09:15:00.000+00:00' }], JETZT)
assert.equal(eintrag.ressourceId, R2)
assert.equal(eintrag.start.toISOString(), '2026-10-12T08:00:00.000Z')
assert.equal(eintrag.ende.toISOString(), '2026-10-12T09:15:00.000Z')
})
test('belegungAus: ohneBuchung nimmt die eigene Buchung heraus', () => {
const von = new Date('2026-10-12T08:00:00Z')
const bis = new Date('2026-10-12T09:00:00Z')
const buchungen = [buchung('eigene', R1, von, bis), buchung('fremde', R1, new Date('2026-10-12T10:00:00Z'), new Date('2026-10-12T11:00:00Z'))]
assert.equal(belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' }).length, 1)
assert.equal(belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' })[0].start.toISOString(), '2026-10-12T10:00:00.000Z')
assert.equal(belegungAus(buchungen, JETZT, {}).length, 2)
assert.equal(belegungAus(buchungen, JETZT).length, 2)
})
test('belegungAus: kaputte Eintraege werden uebersprungen, falsche Parameter bleiben ohne Absturz', () => {
const gut = buchung('g', R1, new Date('2026-10-12T08:00:00Z'), new Date('2026-10-12T09:00:00Z'))
const liste = [
null, undefined, 'x', 42, {},
{ ...gut, $id: 'a', startsAt: 'kein Datum' },
{ ...gut, $id: 'b', blockEndsAt: null },
{ ...gut, $id: 'c', blockEndsAt: gut.startsAt }, // Ende nicht nach Beginn
{ ...gut, $id: 'd', ressourceId: undefined },
gut,
]
assert.deepEqual(belegungAus(liste, JETZT).map((b) => b.ressourceId), [R1])
assert.deepEqual(belegungAus(null, JETZT), [])
assert.throws(() => belegungAus([], undefined), TypeError)
})
// ---------------------------------------------------------------- blockEnde
test('blockEnde: Dauer plus Puffer in echten Minuten', () => {
const start = new Date('2026-10-12T08:00:00Z')
assert.equal(blockEnde(start, { dauer: 45, pufferNach: 15 }).toISOString(), '2026-10-12T09:00:00.000Z')
assert.equal(blockEnde(start, { dauer: 30 }).toISOString(), '2026-10-12T08:30:00.000Z')
assert.equal(start.toISOString(), '2026-10-12T08:00:00.000Z', 'start bleibt unveraendert')
})
test('blockEnde: 60 Minuten um 02:00 am 25.10.2026 dauern real 60 Minuten', () => {
const start = um('2026-10-25', '02:00')
assert.equal(start.toISOString(), '2026-10-25T00:00:00.000Z')
assert.equal(blockEnde(start, { dauer: 60, pufferNach: 0 }).toISOString(), '2026-10-25T01:00:00.000Z')
})
// ---------------------------------------------------------------- Plan, Pause, Puffer, Fensterende
test('Pause: zwischen den Fenstern gibt es keine Startzeiten', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00'], ['13:00', '17:00']]))], raster: 60 })
assert.deepEqual(uhrzeiten(frei(e, MO)), ['09:00', '10:00', '11:00', '13:00', '14:00', '15:00', '16:00'])
})
test('Puffer am Pausenrand: Termin plus Puffer muss ganz ins Fenster passen', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00'], ['13:00', '17:00']]))], dauer: 60, pufferNach: 15, raster: 15 })
const zeiten = uhrzeiten(frei(e, MO))
assert.ok(zeiten.includes('10:45'), 'letzter Start vor der Pause: 10:45 + 75 min = 12:00')
assert.ok(!zeiten.includes('11:00'), '11:00 + 75 min ragt in die Pause')
assert.ok(zeiten.includes('15:45'))
assert.ok(!zeiten.includes('16:00'))
assert.equal(zeiten.at(-1), '15:45')
})
test('letzter Termin endet genau am Fensterende, nicht eine Rasterstufe spaeter', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']]))], dauer: 45, raster: 15 })
const liste = frei(e, MO)
assert.deepEqual(uhrzeiten(liste).slice(-2), ['11:00', '11:15'])
assert.equal(blockEnde(liste.at(-1).start, { dauer: 45, pufferNach: 0 }).getTime(), um(MO, '12:00').getTime())
})
test('Raster beginnt am Fensterbeginn (13:10 mit 30-Minuten-Raster)', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['13:10', '15:00']]))], dauer: 30, raster: 30 })
assert.deepEqual(uhrzeiten(frei(e, MO)), ['13:10', '13:40', '14:10'])
})
test('Fenster bis 24:00 funktioniert', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['22:00', '24:00']]))], dauer: 60, raster: 30 })
assert.deepEqual(uhrzeiten(frei(e, MO)), ['22:00', '22:30', '23:00'])
})
test('Tag ohne Plan ist leer, start ist ein Date in UTC', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG, [1, 2, 3, 4, 5]))] })
assert.deepEqual(frei(e, '2026-10-17'), []) // Samstag
const [erste] = frei(e, MO)
assert.equal(erste.uhrzeit, '09:00')
assert.ok(erste.start instanceof Date)
assert.equal(erste.start.toISOString(), '2026-10-12T07:00:00.000Z')
assert.deepEqual(erste.ressourceIds, [R1])
})
// ---------------------------------------------------------------- Vorlauf, Horizont, Vergangenheit
test('Vorlauf: Start fruehestens now + vorlaufMin (genau gleich ist erlaubt)', () => {
const now = new Date('2026-10-12T06:00:00Z') // Mo 08:00 Berlin
const plan = [ressource(R1, wochenplan([['09:00', '12:00']]))]
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 120 }), MO, { now })), ['10:00', '10:30', '11:00'])
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 60 }), MO, { now })), ['09:00', '09:30', '10:00', '10:30', '11:00'])
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 65 }), MO, { now })), ['09:30', '10:00', '10:30', '11:00'])
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 120 }), MO, { now, ohneRegeln: true })), ['09:00', '09:30', '10:00', '10:30', '11:00'])
})
test('Horizont: letzter buchbarer Tag ist heute + horizontTage', () => {
const e = einstellung({ horizontTage: 7 })
assert.ok(frei(e, '2026-10-08').length > 0, 'Tag 7 ist noch buchbar')
assert.deepEqual(frei(e, '2026-10-09'), [])
assert.ok(frei(e, '2026-10-09', { ohneRegeln: true }).length > 0, 'ohneRegeln hebt den Horizont auf')
assert.ok(frei(e, '2027-03-01', { ohneRegeln: true }).length > 0)
})
test('Horizont zaehlt ab dem Berliner Kalendertag (nicht UTC)', () => {
const e = einstellung({ horizontTage: 1 })
const now = new Date('2026-10-11T22:30:00Z') // schon Mo 12.10. 00:30 Berlin
assert.ok(frei(e, '2026-10-13', { now }).length > 0)
assert.deepEqual(frei(e, '2026-10-14', { now }), [])
})
test('nie Vergangenheit, auch nicht mit ohneRegeln', () => {
const e = einstellung({ raster: 30, vorlaufMin: 240 })
const now = new Date('2026-10-12T10:07:00Z') // 12:07 Berlin
assert.equal(frei(e, MO, { now, ohneRegeln: true })[0].uhrzeit, '12:30')
assert.deepEqual(frei(e, MO, { now }), [], 'mit Vorlauf 240 min: fruehestens 16:07, 16:30 + 60 min passt nicht mehr in den Tag')
assert.deepEqual(frei(e, '2026-10-11', { now, ohneRegeln: true }), [])
assert.deepEqual(frei(e, '2026-09-01', { now, ohneRegeln: true }), [])
})
// ---------------------------------------------------------------- Abwesenheit, Feiertag, Sonderoeffnung
test('Abwesenheit einer Ressource: nur diese Ressource faellt aus, von und bis gelten ganztaegig inklusive', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))],
abwesenheiten: [{ id: 'a_aaaaaaaa', ressourceId: R1, von: '2026-10-12', bis: '2026-10-13', titel: 'Urlaub' }],
})
assert.deepEqual(frei(e, '2026-10-12', { ressourceId: R1 }), [])
assert.deepEqual(frei(e, '2026-10-13', { ressourceId: R1 }), [])
assert.ok(frei(e, '2026-10-14', { ressourceId: R1 }).length > 0)
assert.ok(frei(e, '2026-10-11', { ressourceId: R1 }).length > 0)
assert.deepEqual(frei(e, '2026-10-12', { ressourceId: R2 })[0].ressourceIds, [R2])
assert.deepEqual(frei(e, '2026-10-12')[0].ressourceIds, [R2], 'egal: nur die anwesende Person')
})
test('Abwesenheit des Betriebs (ressourceId null) sperrt alle Ressourcen', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))],
abwesenheiten: [{ id: 'a_bbbbbbbb', ressourceId: null, von: '2026-10-12', bis: '2026-10-14', titel: 'Betriebsurlaub' }],
})
for (const tag of ['2026-10-12', '2026-10-13', '2026-10-14']) {
assert.deepEqual(frei(e, tag), [], tag)
assert.deepEqual(frei(e, tag, { ressourceId: R1 }), [], tag)
}
assert.ok(frei(e, '2026-10-15').length > 0)
})
test('Feiertag schliesst, wenn feiertageGeschlossen; abhaengig vom Bundesland', () => {
const ressourcen = [ressource(R1, wochenplan(TAG))]
const feiertag = '2026-10-03' // Samstag, Tag der Deutschen Einheit
assert.deepEqual(frei(einstellung({ ressourcen }), feiertag), [])
assert.ok(frei(einstellung({ ressourcen, feiertageGeschlossen: false }), feiertag).length > 0)
assert.deepEqual(frei(einstellung({ ressourcen, bundesland: 'BE' }), '2026-12-25'), [])
// Allerheiligen (01.11.2026, Sonntag): in RP Feiertag, in BE nicht
assert.deepEqual(frei(einstellung({ ressourcen, bundesland: 'RP' }), '2026-11-01'), [])
assert.ok(frei(einstellung({ ressourcen, bundesland: 'BE' }), '2026-11-01').length > 0)
})
test('Sonderoeffnung schlaegt den Feiertag und ersetzt den Plan', () => {
const ressourcen = [ressource(R1, wochenplan([['09:00', '17:00']]))]
const feiertag = '2026-10-03'
const e = einstellung({ ressourcen, raster: 60, sonderoeffnungen: { [feiertag]: [['10:00', '13:00']] } })
assert.deepEqual(uhrzeiten(frei(e, feiertag)), ['10:00', '11:00', '12:00'])
// auch an einem normalen Tag ersetzt sie den Plan
const e2 = einstellung({ ressourcen, raster: 60, sonderoeffnungen: { [MO]: [['14:00', '16:00']] } })
assert.deepEqual(uhrzeiten(frei(e2, MO)), ['14:00', '15:00'])
assert.equal(frei(e2, '2026-10-13').length, 8)
})
test('Sonderoeffnung mit leerer Liste: an diesem Tag geschlossen', () => {
const e = einstellung({ sonderoeffnungen: { [MO]: [] } })
assert.deepEqual(frei(e, MO), [])
assert.ok(frei(e, '2026-10-13').length > 0)
})
test('Sonderoeffnung gilt fuer alle Ressourcen, auch fuer solche ohne Plan an diesem Tag', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan([['08:00', '09:00']]))],
raster: 60,
sonderoeffnungen: { [MO]: [['10:00', '12:00']] },
})
const liste = frei(e, MO)
assert.deepEqual(uhrzeiten(liste), ['10:00', '11:00'])
assert.deepEqual(liste[0].ressourceIds, [R1, R2])
})
test('Person im Urlaub ist an einem Sonderoeffnungstag nicht buchbar', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))],
raster: 60,
sonderoeffnungen: { [MO]: [['10:00', '12:00']] },
abwesenheiten: [{ id: 'a_cccccccc', ressourceId: R1, von: MO, bis: MO, titel: 'Urlaub' }],
})
assert.deepEqual(frei(e, MO, { ressourceId: R1 }), [])
assert.deepEqual(frei(e, MO, { ressourceId: R2 }).map((z) => [z.uhrzeit, z.ressourceIds]), [['10:00', [R2]], ['11:00', [R2]]])
assert.deepEqual(frei(e, MO).map((z) => z.ressourceIds), [[R2], [R2]], 'egal: nur die anwesende Person')
})
test('Betriebsurlaub schlaegt Sonderoeffnung', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))],
raster: 60,
sonderoeffnungen: { [MO]: [['10:00', '12:00']], '2026-10-13': [['10:00', '12:00']] },
abwesenheiten: [{ id: 'a_dddddddd', ressourceId: null, von: MO, bis: MO, titel: 'Betriebsurlaub' }],
})
assert.deepEqual(frei(e, MO), [])
assert.deepEqual(frei(e, MO, { ressourceId: R1 }), [])
assert.deepEqual(uhrzeiten(frei(e, '2026-10-13')), ['10:00', '11:00'], 'ohne Abwesenheit gilt die Sonderoeffnung')
})
// ---------------------------------------------------------------- Belegung
test('Belegung blockiert Ueberlappung, angrenzende Zeiten bleiben frei', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '13:00']]))], raster: 30 })
const belegung = [belegt(R1, um(MO, '10:00'), um(MO, '11:00'))]
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung })), ['09:00', '11:00', '11:30', '12:00'])
// 09:30 + 60 min ragt in die Belegung, 09:00 endet genau am Beginn der Belegung
})
test('Belegung mit Puffer: blockEndsAt zaehlt, nicht das Terminende', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '13:00']]))], raster: 15, dauer: 30 })
const belegung = belegungAus([buchung('b', R1, um(MO, '10:00'), um(MO, '10:45'))], JETZT) // 30 min + 15 min Puffer
const zeiten = uhrzeiten(frei(e, MO, { belegung }))
assert.ok(!zeiten.includes('10:30'))
assert.ok(zeiten.includes('10:45'))
assert.ok(zeiten.includes('09:30'))
assert.ok(!zeiten.includes('09:45'), '09:45 + 30 min ragt in die Belegung')
})
test('Belegung einer anderen Ressource stoert nicht', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], raster: 60 })
const belegung = [belegt(R2, um(MO, '09:00'), um(MO, '17:00'))]
assert.equal(frei(e, MO, { ressourceId: R1, belegung }).length, 8)
assert.deepEqual(frei(e, MO, { ressourceId: R2, belegung }), [])
})
test('abgelaufene Haltung blockiert nicht, gueltige Haltung schon', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '11:00']]))], raster: 60 })
const now = new Date('2026-10-12T05:00:00Z') // Mo 07:00 Berlin
const haltung = (heldUntil) => buchung('h', R1, um(MO, '09:00'), um(MO, '10:00'), 'gehalten', { heldUntil: appwrite(heldUntil) })
const abgelaufen = belegungAus([haltung(new Date('2026-10-12T04:59:00Z'))], now)
const gueltig = belegungAus([haltung(new Date('2026-10-12T05:30:00Z'))], now)
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: abgelaufen, now })), ['09:00', '10:00'])
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: gueltig, now })), ['10:00'])
})
test('ohneBuchung gibt die eigene Zeit beim Verschieben frei', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '11:00']]))], raster: 60 })
const buchungen = [buchung('eigene', R1, um(MO, '09:00'), um(MO, '10:00')), buchung('fremde', R1, um(MO, '10:00'), um(MO, '11:00'))]
assert.deepEqual(frei(e, MO, { belegung: belegungAus(buchungen, JETZT) }), [])
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' }) })), ['09:00'])
})
test('Belegung von 22:00 am Vortag bis 06:00 blockiert 05:30', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['05:00', '09:00']]))], raster: 30, dauer: 30 })
const belegung = belegungAus([buchung('nacht', R1, um('2026-10-11', '22:00'), um(MO, '06:00'))], JETZT)
assert.equal(belegung[0].start.toISOString(), '2026-10-11T20:00:00.000Z')
const zeiten = uhrzeiten(frei(e, MO, { belegung }))
assert.ok(!zeiten.includes('05:00'))
assert.ok(!zeiten.includes('05:30'))
assert.deepEqual(zeiten, ['06:00', '06:30', '07:00', '07:30', '08:00', '08:30'])
// am Vortag blockiert dieselbe Belegung 22:00 bis 24:00
const vortag = einstellung({ ressourcen: [ressource(R1, wochenplan([['20:00', '24:00']]))], raster: 60, dauer: 60 })
assert.deepEqual(uhrzeiten(frei(vortag, '2026-10-11', { belegung })), ['20:00', '21:00'])
})
// ---------------------------------------------------------------- Egal, Ressourcen, Leistungen
test('egal: Vereinigung ueber alle Ressourcen der Leistung, ressourceIds nach Liste', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']])), ressource(R2, wochenplan([['11:00', '14:00']]))],
raster: 60,
})
const liste = frei(e, MO)
assert.deepEqual(liste.map((z) => [z.uhrzeit, z.ressourceIds]), [
['09:00', [R1]], ['10:00', [R1]], ['11:00', [R1, R2]], ['12:00', [R2]], ['13:00', [R2]],
])
})
test('egal mit zwei Personen: belegte Person verschwindet nur dort, wo die andere frei ist', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], raster: 60, dauer: 60 })
const belegung = [belegt(R1, um(MO, '10:00'), um(MO, '11:00')), belegt(R1, um(MO, '12:00'), um(MO, '13:00')), belegt(R2, um(MO, '12:00'), um(MO, '13:00'))]
const liste = frei(e, MO, { belegung })
const zu = (uhr) => liste.find((z) => z.uhrzeit === uhr)
assert.deepEqual(zu('09:00').ressourceIds, [R1, R2])
assert.deepEqual(zu('10:00').ressourceIds, [R2])
assert.equal(zu('12:00'), undefined, 'beide belegt: Zeit entfaellt')
assert.deepEqual(zu('13:00').ressourceIds, [R1, R2])
assert.ok(!uhrzeiten(frei(e, MO, { belegung, ressourceId: R1 })).includes('10:00'))
assert.ok(uhrzeiten(frei(e, MO, { belegung, ressourceId: R2 })).includes('10:00'))
})
test('Ressourcen: nur aktive und der Leistung zugeordnete, unbekannte oder nicht zugeordnete ergeben nichts', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG), { aktiv: false }), ressource(R3, wochenplan(TAG))],
zuordnung: [R1, R2],
raster: 60,
})
assert.deepEqual(frei(e, MO)[0].ressourceIds, [R1])
assert.deepEqual(frei(e, MO, { ressourceId: R2 }), [], 'inaktive Ressource')
assert.deepEqual(frei(e, MO, { ressourceId: R3 }), [], 'Leistung nicht zugeordnet')
assert.deepEqual(frei(e, MO, { ressourceId: 'r_unbekannt' }), [])
})
test('Leistung: unbekannt oder inaktiv ergibt nichts, ungueltiger Tag auch', () => {
const e = einstellung()
assert.ok(frei(e, MO).length > 0)
assert.deepEqual(freieZeiten({ einstellung: e, leistungId: 's_unbekannt', ressourceId: 'egal', tag: MO, belegung: [], now: JETZT }), [])
const inaktiv = { ...e, leistungen: [{ ...e.leistungen[0], aktiv: false }] }
assert.deepEqual(frei(inaktiv, MO), [])
for (const tag of ['2026-02-30', '2026-10-1', 'morgen', '', undefined, null, 20261012]) assert.deepEqual(frei(e, tag), [], String(tag))
assert.throws(() => freieZeiten({ einstellung: e, leistungId: S, tag: MO, belegung: [] }), TypeError)
})
test('mit einer echten Branchenvorlage (Salon) liefert die Berechnung Termine', () => {
const e = ausVorlage('salon', { name: 'Salon Test', email: 'salon@example.com' }, JETZT)
const leistung = e.leistungen[0]
const liste = freieZeiten({ einstellung: e, leistungId: leistung.id, ressourceId: 'egal', tag: '2026-10-13', belegung: [], now: JETZT })
assert.ok(liste.length > 0)
for (const z of liste) {
assert.match(z.uhrzeit, /^\d\d:\d\d$/)
assert.ok(z.start instanceof Date)
assert.ok(z.ressourceIds.length > 0)
}
})
// ---------------------------------------------------------------- Zeitumstellung
test('2026-03-29: keine Startzeiten 02:xx, Termine laufen ueber die Luecke in echten Minuten', () => {
const now = new Date('2026-03-01T00:00:00Z')
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 30, raster: 30 })
const liste = frei(e, '2026-03-29', { now })
assert.deepEqual(uhrzeiten(liste), ['00:00', '00:30', '01:00', '01:30', '03:00', '03:30', '04:00', '04:30', '05:00', '05:30'])
assert.ok(!uhrzeiten(liste).some((u) => u.startsWith('02:')))
const start = (uhr) => liste.find((z) => z.uhrzeit === uhr).start.toISOString()
assert.equal(start('01:30'), '2026-03-29T00:30:00.000Z')
assert.equal(start('03:00'), '2026-03-29T01:00:00.000Z') // direkt nach 01:30 + 30 min
assert.equal(start('05:30'), '2026-03-29T03:30:00.000Z')
})
test('2026-03-29: Fenster, das in der Luecke beginnt oder endet', () => {
const now = new Date('2026-03-01T00:00:00Z')
const beginn = einstellung({ ressourcen: [ressource(R1, wochenplan([['02:00', '05:00']]))], dauer: 60, raster: 30 })
assert.deepEqual(uhrzeiten(frei(beginn, '2026-03-29', { now })), ['03:00', '03:30', '04:00'])
const ende = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '02:30']]))], dauer: 30, raster: 30 })
assert.deepEqual(uhrzeiten(frei(ende, '2026-03-29', { now })), ['00:00', '00:30', '01:00', '01:30'])
const nurLuecke = einstellung({ ressourcen: [ressource(R1, wochenplan([['02:00', '03:00']]))], dauer: 30, raster: 30 })
assert.deepEqual(frei(nurLuecke, '2026-03-29', { now }), [])
})
test('2026-10-25: doppelte Stunde, 60-Minuten-Termin um 02:00 dauert real 60 Minuten', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 60, raster: 60 })
const liste = frei(e, '2026-10-25')
assert.deepEqual(uhrzeiten(liste), ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00'], 'jede Uhrzeit hoechstens einmal')
const start = (uhr) => liste.find((z) => z.uhrzeit === uhr).start.toISOString()
assert.equal(start('02:00'), '2026-10-25T00:00:00.000Z', 'erstes Vorkommen (Sommerzeit)')
assert.equal(start('03:00'), '2026-10-25T02:00:00.000Z')
// Fremdbuchung in der zweiten 02-Uhr-Stunde (01:00Z bis 02:00Z): Termine um 02:00 (00:00Z bis 01:00Z) und 03:00 (ab 02:00Z) bleiben frei
const zweiteStunde = [belegt(R1, new Date('2026-10-25T01:00:00Z'), new Date('2026-10-25T02:00:00Z'))]
assert.deepEqual(uhrzeiten(frei(e, '2026-10-25', { belegung: zweiteStunde })), ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00'])
// Fremdbuchung 00:30Z bis 01:30Z blockiert den Termin um 02:00 (00:00Z bis 01:00Z), nicht 01:00 (23:00Z bis 00:00Z) und nicht 03:00 (ab 02:00Z)
const ueberlappt = [belegt(R1, new Date('2026-10-25T00:30:00Z'), new Date('2026-10-25T01:30:00Z'))]
assert.deepEqual(uhrzeiten(frei(e, '2026-10-25', { belegung: ueberlappt })), ['00:00', '01:00', '03:00', '04:00', '05:00'])
})
test('2026-10-25: letzter Termin endet genau um 06:00', () => {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 60, raster: 30 })
const liste = frei(e, '2026-10-25')
assert.equal(liste.at(-1).uhrzeit, '05:00')
assert.equal(blockEnde(liste.at(-1).start, { dauer: 60, pufferNach: 0 }).toISOString(), '2026-10-25T05:00:00.000Z') // 06:00 MEZ
})
test('Umstelltage: Ergebnis entspricht einer Referenzrechnung je Startzeit (zuUtc je Kandidat)', () => {
const dauer = 30
const raster = 15
const mins = (hhmm) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3))
const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
const fenster = [['00:00', '06:00'], ['01:00', '04:00'], ['02:00', '05:00'], ['00:00', '02:30'], ['02:30', '06:00'], ['08:00', '24:00'], ['22:00', '24:00'], ['09:00', '17:00']]
for (const tag of ['2026-03-27', '2026-03-28', '2026-03-29', '2026-03-30', '2026-10-24', '2026-10-25', '2026-10-26']) {
const now = tag < '2026-06' ? new Date('2026-03-01T00:00:00Z') : JETZT
for (const [von, bis] of fenster) {
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([[von, bis]]))], dauer, raster })
const ende = (zuUtc(tag, bis) || zuUtc(tag, '03:00')).getTime()
const erwartet = []
for (let m = mins(von); m < mins(bis); m += raster) {
const d = zuUtc(tag, hhmm(m))
if (d && d.getTime() + dauer * 60000 <= ende) erwartet.push([hhmm(m), d.toISOString()])
}
const ist = frei(e, tag, { now }).map((z) => [z.uhrzeit, z.start.toISOString()])
assert.deepEqual(ist, erwartet, `${tag} ${von}-${bis}`)
}
}
})
// ---------------------------------------------------------------- freieTage
test('freieTage: Tage mit mindestens einer freien Zeit, ohne Feiertage und volle Tage', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan(TAG, [1, 2, 3, 4, 5, 6]))],
abwesenheiten: [{ id: 'a_dddddddd', ressourceId: null, von: '2026-10-19', bis: '2026-10-20', titel: 'Fortbildung' }],
})
const belegung = [belegt(R1, um('2026-10-14', '08:00'), um('2026-10-14', '18:00'))]
const tage = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung, now: JETZT })
assert.deepEqual(tage, [
'2026-10-01', '2026-10-02', /* 03. Feiertag */ '2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10',
'2026-10-12', '2026-10-13', /* 14. voll */ '2026-10-15', '2026-10-16', '2026-10-17',
/* 19./20. abwesend */ '2026-10-21', '2026-10-22', '2026-10-23', '2026-10-24',
'2026-10-26', '2026-10-27', '2026-10-28', '2026-10-29', '2026-10-30', '2026-10-31',
])
})
test('freieTage: vergangene Tage, Vorlauf und Horizont begrenzen den Monat', () => {
const e = einstellung({ horizontTage: 10, vorlaufMin: 120 })
const jetzt = new Date('2026-10-05T14:00:00Z') // Mo 16:00 Berlin: 16:00 + 2 h = 18:00, heute nichts mehr
const tage = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung: [], now: jetzt })
assert.equal(tage[0], '2026-10-06')
assert.equal(tage.at(-1), '2026-10-15')
const ohne = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung: [], now: jetzt, ohneRegeln: true })
assert.equal(ohne[0], '2026-10-05', 'ohneRegeln: heute 16:00 + 60 min passt noch bis 17:00')
assert.equal(ohne.at(-1), '2026-10-31')
})
test('freieTage: jeder Tag stimmt mit freieZeiten ueberein, ungueltiger Monat ergibt nichts', () => {
const e = einstellung({
ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']], [1, 3, 5])), ressource(R2, wochenplan([['10:00', '11:00']], [2]))],
dauer: 60,
})
const belegung = [belegt(R1, um('2026-11-04', '09:00'), um('2026-11-04', '12:00'))]
const basis = { einstellung: e, leistungId: S, ressourceId: 'egal', belegung, now: JETZT }
const tage = freieTage({ ...basis, monat: '2026-11' })
assert.ok(tage.length > 5)
for (let i = 1; i <= 30; i += 1) {
const tag = `2026-11-${String(i).padStart(2, '0')}`
assert.equal(tage.includes(tag), freieZeiten({ ...basis, tag }).length > 0, tag)
}
for (const monat of ['2026-13', '2026-00', '2026-1', 'Oktober', '', undefined, null]) assert.deepEqual(freieTage({ ...basis, monat }), [], String(monat))
assert.deepEqual(freieTage({ ...basis, leistungId: 's_unbekannt', monat: '2026-11' }), [])
assert.deepEqual(freieTage({ ...basis, ressourceId: R3, monat: '2026-11' }), [])
})
test('freieTage: Februar und Schaltjahr haben die richtige Tageszahl', () => {
const e = einstellung({ horizontTage: 365 * 3 })
const basis = { einstellung: e, leistungId: S, ressourceId: 'egal', belegung: [], now: JETZT }
assert.equal(freieTage({ ...basis, monat: '2027-02' }).at(-1), '2027-02-28')
assert.equal(freieTage({ ...basis, monat: '2028-02' }).at(-1), '2028-02-29')
})
// ---------------------------------------------------------------- waehleRessource
test('waehleRessource: die Ressource mit den wenigsten Buchungen an dem Tag', () => {
const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {}), ressource(R3, {})] })
const tagesBuchungen = [
{ ressourceId: R1, status: 'bestaetigt' }, { ressourceId: R1, status: 'angefragt' },
{ ressourceId: R2, status: 'bestaetigt' },
{ ressourceId: R3, status: 'bestaetigt' }, { ressourceId: R3, status: 'bestaetigt' }, { ressourceId: R3, status: 'bestaetigt' },
]
assert.equal(waehleRessource([R1, R2, R3], tagesBuchungen, e), R2)
assert.equal(waehleRessource([R1, R3], tagesBuchungen, e), R1)
assert.equal(waehleRessource([R3], tagesBuchungen, e), R3)
})
test('waehleRessource: bei Gleichstand gilt die Reihenfolge der Liste, abgesagte Termine und Sperren zaehlen nicht', () => {
const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {}), ressource(R3, {})] })
assert.equal(waehleRessource([R3, R2, R1], [], e), R1)
assert.equal(waehleRessource([R2, R3], [], e), R2)
const tagesBuchungen = [
{ ressourceId: R1, status: 'abgesagt' }, { ressourceId: R1, status: 'verfallen' }, { ressourceId: R1, status: 'abgelehnt' },
{ ressourceId: R1, status: 'bestaetigt', quelle: 'sperre' },
{ ressourceId: R2, status: 'bestaetigt' },
]
assert.equal(waehleRessource([R2, R1], tagesBuchungen, e), R1)
assert.equal(waehleRessource([R2, R1], [{ ressourceId: R1, status: 'erledigt' }], e), R2, 'erledigte Termine des Tages zaehlen mit')
})
test('waehleRessource: mit now zaehlt eine abgelaufene Haltung nicht, eine gueltige schon', () => {
const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {})] })
const now = new Date('2026-10-12T08:00:00Z')
const haltung = (ressourceId, heldUntil) => ({ ressourceId, status: 'gehalten', heldUntil: heldUntil && appwrite(new Date(heldUntil)) })
const abgelaufen = [haltung(R1, '2026-10-12T07:59:00Z'), haltung(R1, '2026-10-12T08:00:00Z'), haltung(R1, undefined)]
assert.equal(waehleRessource([R1, R2], abgelaufen, e), R2, 'ohne now zaehlt jede Haltung')
assert.equal(waehleRessource([R2, R1], abgelaufen, e, now), R1, 'mit now: Gleichstand 0:0, Reihenfolge der Einstellung')
assert.equal(waehleRessource([R1, R2], [haltung(R1, '2026-10-12T08:00:01Z')], e, now), R2, 'gueltige Haltung zaehlt')
assert.equal(waehleRessource([R1, R2], [...abgelaufen, { ressourceId: R1, status: 'bestaetigt' }], e, now), R2, 'andere Status zaehlen weiter')
assert.throws(() => waehleRessource([R1], [], e, 'gestern'), TypeError)
})
test('waehleRessource: keine Kandidaten ergibt null, fremde Ressourcen werden nicht gezaehlt', () => {
const e = einstellung()
assert.equal(waehleRessource([], [], e), null)
assert.equal(waehleRessource(undefined, [], e), null)
assert.equal(waehleRessource([R1], [{ ressourceId: R2, status: 'bestaetigt' }], e), R1)
assert.equal(waehleRessource(['r_unbekannt', R1], [], e), R1, 'bekannte Ressourcen der Einstellung zuerst')
})