Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet), Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze, Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit Rueckfragen in der Seite, Termin eintragen und Zeit sperren. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
243 lines
15 KiB
JavaScript
243 lines
15 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import {
|
||
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
|
||
fehltText, heuteBerlin, istSperre, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
|
||
} from '../public/termine-view.js'
|
||
|
||
// Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird
|
||
// nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit.
|
||
|
||
test('fensterParsen: Zeitfenster aus Text, normalisiert', () => {
|
||
assert.deepEqual(fensterParsen('09:00–12:30, 13:30–18:00'), [['09:00', '12:30'], ['13:30', '18:00']])
|
||
assert.deepEqual(fensterParsen('9:00 - 12:30; 13:30 bis 18:00'), [['09:00', '12:30'], ['13:30', '18:00']])
|
||
assert.deepEqual(fensterParsen('9.00—24:00'), [['09:00', '24:00']])
|
||
assert.deepEqual(fensterParsen('08:00-12:00 und 14:00-18:00'), [['08:00', '12:00'], ['14:00', '18:00']])
|
||
assert.deepEqual(fensterParsen(' '), [], 'leer = an diesem Tag geschlossen')
|
||
assert.deepEqual(fensterParsen(''), [])
|
||
})
|
||
|
||
test('fensterParsen: ungültige Eingaben ⇒ null', () => {
|
||
for (const schlecht of [
|
||
'abc', '09:00', '09:00–', '–12:00', '12:00–09:00', '09:00–09:00', '09:07–12:00', '25:00–26:00', '24:00–24:00',
|
||
'09:00–12:00, 11:00–13:00', '13:00–14:00, 09:00–10:00', '09:00–12:00,', '09:00–12:00 13:00–14:00', null, undefined, 5,
|
||
]) {
|
||
assert.equal(fensterParsen(schlecht), null, String(schlecht))
|
||
}
|
||
})
|
||
|
||
test('fensterText ist die Umkehrung von fensterParsen', () => {
|
||
assert.equal(fensterText([['09:00', '12:30'], ['13:30', '18:00']]), '09:00–12:30, 13:30–18:00')
|
||
assert.equal(fensterText([]), '')
|
||
assert.equal(fensterText(undefined), '')
|
||
assert.deepEqual(fensterParsen(fensterText([['08:00', '24:00']])), [['08:00', '24:00']])
|
||
})
|
||
|
||
test('zeitNormal: HH:MM, Ende darf 24:00 sein', () => {
|
||
assert.equal(zeitNormal('9:05'), '09:05')
|
||
assert.equal(zeitNormal(' 09:00 '), '09:00')
|
||
assert.equal(zeitNormal('17.30'), '17:30')
|
||
assert.equal(zeitNormal('24:00'), null)
|
||
assert.equal(zeitNormal('24:00', { ende: true }), '24:00')
|
||
assert.equal(zeitNormal('24:05', { ende: true }), null)
|
||
assert.equal(zeitNormal('12:07'), '12:07', 'Zeit ohne 5er-Raster wird nur normalisiert, der Server prüft das Raster')
|
||
assert.equal(zeitNormal('12:60'), null)
|
||
assert.equal(zeitNormal('abc'), null)
|
||
assert.equal(zeitNormal(''), null)
|
||
assert.equal(zeitNormal(undefined), null)
|
||
})
|
||
|
||
test('statusLabel: alle acht Status deutsch, Unbekanntes nie roh', () => {
|
||
assert.deepEqual(
|
||
['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen'].map(statusLabel),
|
||
['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen'],
|
||
)
|
||
assert.equal(statusLabel('geheim<b>'), 'Unbekannt')
|
||
assert.equal(statusLabel(undefined), 'Unbekannt')
|
||
for (const s of ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) {
|
||
assert.match(statusTon(s), /^(ok|wait|off|bad)$/, s)
|
||
}
|
||
assert.equal(statusTon('bestaetigt'), 'ok')
|
||
assert.equal(statusTon('angefragt'), 'wait')
|
||
})
|
||
|
||
test('Kalender: wocheVon (Montag bis Sonntag), monatVon, tagPlus über Monats- und Jahresgrenzen', () => {
|
||
assert.deepEqual(wocheVon('2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' }, 'Montag')
|
||
assert.deepEqual(wocheVon('2026-10-11'), { von: '2026-10-05', bis: '2026-10-11' }, 'Sonntag gehört zur Woche davor')
|
||
assert.deepEqual(wocheVon('2026-10-07'), { von: '2026-10-05', bis: '2026-10-11' })
|
||
assert.deepEqual(wocheVon('2026-12-31'), { von: '2026-12-28', bis: '2027-01-03' }, 'Jahreswechsel')
|
||
assert.deepEqual(monatVon('2026-10-15'), { von: '2026-10-01', bis: '2026-10-31' })
|
||
assert.deepEqual(monatVon('2028-02-10'), { von: '2028-02-01', bis: '2028-02-29' }, 'Schaltjahr')
|
||
assert.deepEqual(monatVon('2026-12-01'), { von: '2026-12-01', bis: '2026-12-31' })
|
||
assert.equal(tagPlus('2026-10-31', 1), '2026-11-01')
|
||
assert.equal(tagPlus('2026-01-01', -1), '2025-12-31')
|
||
assert.equal(tagPlus('2026-03-29', 1), '2026-03-30', 'Zeitumstellung spielt beim Kalender keine Rolle')
|
||
assert.equal(tagPlus('2026-10-25', 1), '2026-10-26')
|
||
})
|
||
|
||
test('bereichFuer und verschiebeTag je Ansicht', () => {
|
||
assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' })
|
||
assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' })
|
||
assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' })
|
||
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-06' }, 'ab heute, 62 Tage (Grenze der Schnittstelle)')
|
||
assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01')
|
||
assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30')
|
||
assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12')
|
||
assert.equal(verschiebeTag('woche', '2026-10-05', -1), '2026-09-28')
|
||
assert.equal(verschiebeTag('monat', '2026-10-17', 1), '2026-11-01')
|
||
assert.equal(verschiebeTag('monat', '2026-01-17', -1), '2025-12-01')
|
||
assert.equal(verschiebeTag('monat', '2026-12-31', 1), '2027-01-01')
|
||
})
|
||
|
||
test('Daten: datumLang, datumKurz, tagUeberschrift, zeitraumTitel', () => {
|
||
assert.equal(datumLang('2026-10-05'), 'Montag, 5. Oktober 2026')
|
||
assert.equal(datumLang('2026-03-01'), 'Sonntag, 1. März 2026')
|
||
assert.equal(datumKurz('2026-10-05'), '05.10.2026')
|
||
assert.equal(tagUeberschrift('2026-10-05', '2026-10-05'), 'Heute · Montag, 5. Oktober')
|
||
assert.equal(tagUeberschrift('2026-10-06', '2026-10-05'), 'Morgen · Dienstag, 6. Oktober')
|
||
assert.equal(tagUeberschrift('2026-10-04', '2026-10-05'), 'Gestern · Sonntag, 4. Oktober')
|
||
assert.equal(tagUeberschrift('2026-10-12', '2026-10-05'), 'Montag, 12. Oktober')
|
||
assert.equal(tagUeberschrift('2027-01-04', '2026-10-05'), 'Montag, 4. Januar 2027', 'anderes Jahr nennt das Jahr')
|
||
assert.equal(zeitraumTitel('tag', '2026-10-05'), 'Montag, 5. Oktober 2026')
|
||
assert.equal(zeitraumTitel('woche', '2026-10-07'), '5.–11. Oktober 2026')
|
||
assert.equal(zeitraumTitel('woche', '2026-10-28'), '26. Oktober – 1. November 2026')
|
||
assert.equal(zeitraumTitel('woche', '2026-12-30'), '28. Dezember 2026 – 3. Januar 2027')
|
||
assert.equal(zeitraumTitel('monat', '2026-10-17'), 'Oktober 2026')
|
||
assert.equal(zeitraumTitel('anfragen', '2026-10-17'), 'Offene Anfragen')
|
||
})
|
||
|
||
test('heuteBerlin: der Berliner Kalendertag, nicht der des Browsers', () => {
|
||
assert.equal(heuteBerlin(new Date('2026-10-04T22:30:00Z')), '2026-10-05', '00:30 Berlin (MESZ) ist schon Montag')
|
||
assert.equal(heuteBerlin(new Date('2026-10-05T21:59:00Z')), '2026-10-05')
|
||
assert.equal(heuteBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01', 'MEZ')
|
||
assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
|
||
})
|
||
|
||
test('endeZeit: Ende des Termins in Berliner Wanduhr aus endsAt', () => {
|
||
assert.equal(endeZeit({ endsAt: '2026-10-05T08:45:00.000Z' }), '10:45')
|
||
assert.equal(endeZeit({ endsAt: '2026-12-01T08:45:00.000Z' }), '09:45', 'Winterzeit')
|
||
assert.equal(endeZeit({ endsAt: '2026-10-05T22:00:00.000Z' }), '00:00')
|
||
assert.equal(endeZeit({ endsAt: 'kaputt' }), '')
|
||
assert.equal(endeZeit({}), '')
|
||
})
|
||
|
||
const B = (extra = {}) => ({
|
||
id: 'b1', status: 'bestaetigt', quelle: 'online', startsAt: '2026-10-05T08:00:00.000Z', endsAt: '2026-10-05T08:45:00.000Z', datum: '2026-10-05', zeit: '10:00',
|
||
gastName: 'Max', gastEmail: 'max@example.com', gastTelefon: '', gastNachricht: '', ...extra,
|
||
})
|
||
|
||
test('gruppiereNachTag: nach Berliner Tag, Tage und Termine aufsteigend, Termine der Liste bleiben vollständig', () => {
|
||
const liste = [
|
||
B({ id: 'c', datum: '2026-10-06', zeit: '09:00', startsAt: '2026-10-06T07:00:00.000Z' }),
|
||
B({ id: 'b', datum: '2026-10-05', zeit: '11:00', startsAt: '2026-10-05T09:00:00.000Z' }),
|
||
B({ id: 'a', datum: '2026-10-05', zeit: '10:00', startsAt: '2026-10-05T08:00:00.000Z' }),
|
||
]
|
||
const gruppen = gruppiereNachTag(liste)
|
||
assert.deepEqual(gruppen.map((g) => g.tag), ['2026-10-05', '2026-10-06'])
|
||
assert.deepEqual(gruppen[0].buchungen.map((b) => b.id), ['a', 'b'])
|
||
assert.deepEqual(gruppen[1].buchungen.map((b) => b.id), ['c'])
|
||
assert.deepEqual(gruppiereNachTag([]), [])
|
||
assert.deepEqual(gruppiereNachTag(undefined), [])
|
||
assert.deepEqual(liste.map((b) => b.id), ['c', 'b', 'a'], 'Eingabe bleibt unverändert')
|
||
})
|
||
|
||
test('istSperre: nur quelle sperre', () => {
|
||
assert.equal(istSperre({ quelle: 'sperre' }), true)
|
||
assert.equal(istSperre({ quelle: 'manuell' }), false)
|
||
assert.equal(istSperre({ quelle: 'online' }), false)
|
||
assert.equal(istSperre({}), false)
|
||
})
|
||
|
||
test('moeglicheAktionen: je Status und Zeit; Sperren nie mit Gast-Aktionen', () => {
|
||
const vorher = Date.parse('2026-10-05T07:00:00Z') // vor Beginn
|
||
const mitten = Date.parse('2026-10-05T08:10:00Z') // nach Beginn
|
||
const danach = Date.parse('2026-10-05T09:00:00Z') // nach Ende
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), vorher), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren'])
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'angefragt' }), mitten), ['ablehnen', 'anonymisieren'], 'begonnene Anfrage lässt sich nicht mehr annehmen')
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), vorher), ['verschieben', 'absagen', 'anonymisieren'])
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), mitten), ['nichterschienen', 'anonymisieren'])
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'bestaetigt' }), danach), ['nichterschienen', 'anonymisieren'])
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'erledigt' }), danach), ['nichterschienen', 'anonymisieren'])
|
||
for (const s of ['nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']) assert.deepEqual(moeglicheAktionen(B({ status: s }), danach), ['anonymisieren'], s)
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'gehalten' }), vorher), [], 'Reservierung wartet auf den Gast')
|
||
// Gastdaten schon gelöscht oder nie erfasst: nichts zu löschen
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: '' }), danach), [])
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: '', gastEmail: '' }), danach), [])
|
||
assert.deepEqual(moeglicheAktionen(B({ status: 'abgesagt', gastName: 'Gelöscht', gastEmail: 'x@y.de' }), danach), ['anonymisieren'])
|
||
// Sperre: nur aufheben, solange sie noch nicht vorbei ist
|
||
assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), vorher), ['entsperren'])
|
||
assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'bestaetigt', gastName: '' }), mitten), ['entsperren'])
|
||
assert.deepEqual(moeglicheAktionen(B({ quelle: 'sperre', status: 'erledigt', gastName: '' }), danach), [])
|
||
})
|
||
|
||
test('Filter: Statuslisten für die Schnittstelle', () => {
|
||
assert.deepEqual(FILTER.map((f) => f.key), ['alle', 'offen', 'angefragt', 'bestaetigt', 'abgesagt'])
|
||
assert.equal(filterStatus('alle'), '')
|
||
assert.equal(filterStatus('offen'), 'angefragt,bestaetigt')
|
||
assert.equal(filterStatus('angefragt'), 'angefragt')
|
||
assert.equal(filterStatus('bestaetigt'), 'bestaetigt')
|
||
assert.equal(filterStatus('abgesagt'), 'abgesagt,abgelehnt,verfallen')
|
||
assert.equal(filterStatus('gibtsnicht'), '')
|
||
})
|
||
|
||
test('anfragenText: Einzahl und Mehrzahl', () => {
|
||
assert.equal(anfragenText(0), '')
|
||
assert.equal(anfragenText(1), '1 Anfrage wartet')
|
||
assert.equal(anfragenText(2), '2 Anfragen warten')
|
||
assert.equal(anfragenText(17), '17 Anfragen warten')
|
||
assert.equal(anfragenText(undefined), '')
|
||
})
|
||
|
||
test('Absagegründe: Auswahl mit kurzen Texten (Server: höchstens 100 Zeichen)', () => {
|
||
assert.ok(ABSAGE_GRUENDE.length >= 4)
|
||
for (const g of ABSAGE_GRUENDE) assert.ok(g.length > 0 && [...g].length <= 100, g)
|
||
assert.equal(new Set(ABSAGE_GRUENDE).size, ABSAGE_GRUENDE.length)
|
||
})
|
||
|
||
test('ressourcenWort: Person, Platz oder beides', () => {
|
||
assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'person' }]), 'Person')
|
||
assert.equal(ressourcenWort([{ typ: 'platz' }]), 'Platz')
|
||
assert.equal(ressourcenWort([{ typ: 'person' }, { typ: 'platz' }]), 'Person/Platz')
|
||
assert.equal(ressourcenWort([]), 'Person/Platz')
|
||
assert.equal(ressourcenWort(undefined), 'Person/Platz')
|
||
})
|
||
|
||
test('fehltText: Pflichtangaben für Gäste in Worten', () => {
|
||
assert.equal(fehltText(['betrieb.telefon']), 'deine Telefonnummer')
|
||
assert.equal(fehltText(['betrieb.telefon', 'betrieb.adresse']), 'deine Telefonnummer und deine Anschrift')
|
||
assert.equal(fehltText(['betrieb.adresse']), 'deine Anschrift')
|
||
assert.equal(fehltText([]), '')
|
||
assert.equal(fehltText(['unbekannt']), '', 'Unbekanntes nie roh')
|
||
assert.equal(fehltText(undefined), '')
|
||
})
|
||
|
||
// ---- Verdrahtung (Portal-Ansicht #termine)
|
||
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||
|
||
test('Portal: Ansicht #termine ist verdrahtet, funktionen.js behält seinen einen absoluten Import', () => {
|
||
const app = lies('public/app.js')
|
||
assert.match(app, /VIEW_HASHES = \{[^}]*termine: 'termine'/)
|
||
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen'/)
|
||
assert.match(app, /FEATURE_LABELS = \{[^}]*termine: 'Terminplaner'/)
|
||
assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/)
|
||
const html = lies('public/dashboard.html')
|
||
assert.match(html, /<section class="kb-view" id="view-termine">[\s\S]*<h1>Terminplaner<\/h1>[\s\S]*<div id="termine-body">/)
|
||
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">/)
|
||
// R4: funktionen.js lädt nichts Neues (die Kachel steht inline); test/funktionenTiles.test.js biegt genau diesen Import um
|
||
const fn = lies('public/funktionen.js')
|
||
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'"])
|
||
assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import')
|
||
})
|
||
|
||
test('termine.js: Fremdtext nur als Text, keine Browser-Dialoge, Du-Form', () => {
|
||
const quelle = lies('public/termine.js').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') // ohne Kommentare
|
||
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML')
|
||
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite')
|
||
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form')
|
||
// Links nur auf die eigene Export-Schnittstelle, nie auf Kundentext
|
||
const hrefs = quelle.match(/href: `[^`]*`/g) || []
|
||
assert.deepEqual(hrefs, ['href: `/api/termine/export.csv?projectId=${encodeURIComponent(projectId)}`'])
|
||
assert.doesNotMatch(quelle, /href: [^`]|setAttribute\('href'/, 'sonst kein href')
|
||
})
|