From edaefd4237d14c3705d3f3a0b98ba0d886dc47b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:48:10 +0000 Subject: [PATCH] Terminplaner TP2: Fragebogen-Helfer im Client Reine Helfer in public/termine-view.js: FRAGEBOGEN_SCHRITTE (Spiegel von SCHRITTE und SCHRITT_TITEL), schrittStatus, ersterOffenerSchritt, ressourcenAusNamen, buchungsartFuerAlle, buchungsartLage und zusammenfassung (acht Gruppen, nur Text, Bundesland nur bei ausdruecklicher Wahl, nie feedToken). Tests in test/termineFragebogenView.test.js, Spiegel-Test in test/termineView.test.js. Co-Authored-By: Claude Sonnet 5.5 --- public/termine-view.js | 245 ++++++++++++++++ test/termineFragebogenView.test.js | 455 +++++++++++++++++++++++++++++ test/termineView.test.js | 8 +- 3 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 test/termineFragebogenView.test.js diff --git a/public/termine-view.js b/public/termine-view.js index 8d1b007..98dc6d4 100644 --- a/public/termine-view.js +++ b/public/termine-view.js @@ -2,6 +2,8 @@ Tage sind Berliner Kalendertage 'JJJJ-MM-TT' (so liefert und erwartet sie /api/termine). Gerechnet wird nur mit dem Kalender (UTC-Mitternacht), nie mit der Zeitzone des Browsers; nur heuteBerlin() und endeZeit() fragen die Uhr bzw. Zeitzone Berlin. */ +import { ANREDE_WAHL } from './termine-gaestetexte.js' + const TAG_RE = /^(\d{4})-(\d{2})-(\d{2})$/ const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] @@ -475,3 +477,246 @@ export function abschaltText(abgeschaltet) { : 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ' return { davor, danach: `.${loeschen}`, mitExport: true } } + +/* --------------------------------------------------------- Fragebogen (TP2) --- */ +/** + * Die acht Schritte des Fragebogens als [Schluessel, Titel], in der Reihenfolge des Servers. Spiegel von SCHRITTE und SCHRITT_TITEL in + * server/services/termine/fragebogen.js (test/termineView.test.js vergleicht beide; der Titel steht auch in „Bitte bestaetige den Schritt …“). + * Der neunte Schritt „Pruefen und bestellen“ ist kein Fragebogen-Schritt und wird nie bestaetigt. + */ +export const FRAGEBOGEN_SCHRITTE = [ + ['branche', 'Branche und Betrieb'], + ['wer', 'Wer wird gebucht'], + ['leistungen', 'Leistungen'], + ['zeiten', 'Zeiten'], + ['regeln', 'Regeln'], + ['gastfelder', 'Gastfelder'], + ['benachrichtigung', 'Benachrichtigung'], + ['website', 'Website'], +] + +/** Stand der Schritte aus fragebogen.bestaetigt: [{ key, titel, erledigt }] in Schrittfolge. Ohne Fragebogen ist nichts erledigt. */ +export function schrittStatus(fragebogen) { + const bestaetigt = Array.isArray(fragebogen?.bestaetigt) ? fragebogen.bestaetigt : [] + return FRAGEBOGEN_SCHRITTE.map(([key, titel]) => ({ key, titel, erledigt: bestaetigt.includes(key) })) +} + +/** Schluessel des ersten Schritts ohne Haken (die erste Luecke, nicht die Zahl der Haken); sind alle bestaetigt: 'bestellen'. */ +export function ersterOffenerSchritt(fragebogen) { + return schrittStatus(fragebogen).find((schritt) => !schritt.erledigt)?.key ?? 'bestellen' +} + +const leerzeichenEins = (wert) => wert.replace(/\s+/g, ' ').trim() +const nameSchluessel = (name) => leerzeichenEins(name).normalize('NFC').toLowerCase() + +/** + * Personen oder Plaetze aus den Namen im Schritt „Wer wird gebucht“. bisher = ressourcen der Einstellung, namen = Liste von Texten, + * typ = 'person' | 'platz' (gilt fuer alle, auch fuer behaltene Eintraege; sonst Person), neueId(belegt) = Erzeuger einer freien ID + * (belegt: Set aller schon vergebenen IDs inklusive der alten, auch der wegfallenden; Vorgabe eindeutigeId('r', belegt); eine belegte ID wirft). + * Namen: getrimmt, Leerraum zusammengefasst, auf MAX.ressourcenName gekuerzt, ohne Leere und ohne Doppelte (Gross-/Kleinschreibung zaehlt + * nicht, die erste Schreibweise gilt), hoechstens LIMITS.ressourcen. Zuordnung: gleiche Namen behalten ID und Plan; uebrige alte Eintraege + * werden in ihrer Reihenfolge den uebrigen Namen zugeteilt (umbenannt, ID und Plan bleiben); weitere Namen bekommen eine neue ID und eine + * Kopie des Plans des ersten Eintrags (ohne Eintrag: leerer Plan). Alle Eintraege sind aktiv. Ergebnis in der Reihenfolge der Namen: + * { ressourcen, entfernt: IDs der weggefallenen Eintraege, neu: IDs der neuen }. Die Eintraege tragen nur Felder, die der Server aus dem + * Client uebernimmt (kein feedToken, den vergibt und behaelt der Server). Die Zuordnung der Leistungen setzt der Aufrufer. + */ +export function ressourcenAusNamen({ bisher, typ, namen, neueId: erzeugeId = (belegt) => eindeutigeId('r', belegt) } = {}) { + const art = typ === 'platz' ? 'platz' : 'person' + const alt = (Array.isArray(bisher) ? bisher : []).filter((r) => r !== null && typeof r === 'object' && typeof r.id === 'string') + + const sauber = [] + const gesehen = new Set() + for (const roh of Array.isArray(namen) ? namen : []) { + if (typeof roh !== 'string') continue + const name = leerzeichenEins([...leerzeichenEins(roh)].slice(0, MAX.ressourcenName).join('')) + if (name === '' || gesehen.has(nameSchluessel(name))) continue + gesehen.add(nameSchluessel(name)) + sauber.push(name) + if (sauber.length === LIMITS.ressourcen) break + } + + // 1. gleiche Namen behalten ihren alten Eintrag, 2. die uebrigen alten Eintraege gehen der Reihe nach an die uebrigen Namen + const frei = new Set(alt) + const zugeteilt = sauber.map((name) => { + const treffer = alt.find((r) => frei.has(r) && typeof r.name === 'string' && nameSchluessel(r.name) === nameSchluessel(name)) + if (treffer) frei.delete(treffer) + return treffer ?? null + }) + const uebrige = alt.filter((r) => frei.has(r)) + sauber.forEach((name, i) => { if (zugeteilt[i] === null && uebrige.length > 0) zugeteilt[i] = uebrige.shift() }) + + const belegt = new Set(alt.map((r) => r.id)) + const ressourcen = [] + const neu = [] + sauber.forEach((name, i) => { + const alter = zugeteilt[i] + let id + let plan + if (alter) { + id = alter.id + plan = alter.plan + } else { + id = erzeugeId(new Set(belegt)) + if (typeof id !== 'string' || belegt.has(id)) throw new Error(`neueId lieferte keine freie ID: ${String(id).slice(0, 40)}`) + belegt.add(id) + neu.push(id) + plan = ressourcen[0]?.plan + } + ressourcen.push({ id, typ: art, name, aktiv: true, plan: structuredClone(plan !== null && typeof plan === 'object' ? plan : {}) }) + }) + return { ressourcen, entfernt: uebrige.map((r) => r.id), neu } +} + +const BUCHUNGSARTEN = ['sofort', 'anfrage'] + +/** Kopie der Leistungen, in der jede (auch eine inaktive) die Buchungsart art hat; sonst aendert sich nichts. Unbekannte Art: unveraenderte Kopie. */ +export function buchungsartFuerAlle(leistungen, art) { + return (Array.isArray(leistungen) ? leistungen : []).map((leistung) => (BUCHUNGSARTEN.includes(art) ? { ...leistung, buchungsart: art } : { ...leistung })) +} + +/** 'sofort' | 'anfrage', wenn alle Leistungen gleich sind (auch ohne Leistungen und bei fehlender Angabe: sofort, wie beim Server), sonst 'gemischt'. */ +export function buchungsartLage(leistungen) { + const arten = new Set((Array.isArray(leistungen) ? leistungen : []).map((leistung) => (leistung?.buchungsart === 'anfrage' ? 'anfrage' : 'sofort'))) + if (arten.size > 1) return 'gemischt' + return arten.has('anfrage') ? 'anfrage' : 'sofort' +} + +// ---- Zusammenfassung auf der Bestellseite + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const istText = (wert) => typeof wert === 'string' && wert.trim() !== '' +const einheit = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}` +const wahlName = (wahl, key) => wahl.find(([k]) => k === key)?.[1] + +/** Kundentext als eine Zeile: Umbrueche werden Leerzeichen, ueber max Zeichen gekuerzt (mit „…“). */ +function kurzText(wert, max) { + const zeichen = [...leerzeichenEins(wert)] + return zeichen.length > max ? `${zeichen.slice(0, max - 1).join('').trimEnd()}…` : zeichen.join('') +} + +/** 0 ⇒ 'keine', ganze Tage, ganze Stunden, sonst Minuten. */ +function zeitspanne(minuten) { + if (minuten === 0) return 'keine' + if (minuten % 1440 === 0) return einheit(minuten / 1440, 'Tag', 'Tage') + if (minuten % 60 === 0) return einheit(minuten / 60, 'Stunde', 'Stunden') + return einheit(minuten, 'Minute', 'Minuten') +} + +/** Wochenplan einer Ressource als Zeilen: aufeinanderfolgende Tage mit gleichen Zeiten zusammengefasst, danach 'Geschlossen: …'. */ +function wochenplanZeilen(plan, vorsatz) { + const texte = planTexte(plan) + const tage = WOCHENTAGE_PLAN + const bereich = (von, bis) => (von === bis ? tage[von][1] : `${tage[von][1]}–${tage[bis][1]}`) + const offen = [] + const zu = [] + for (let i = 0; i < tage.length;) { + const text = texte[tage[i][0]] + let bis = i + while (bis + 1 < tage.length && texte[tage[bis + 1][0]] === text) bis += 1 + if (text === '') zu.push(bereich(i, bis)) + else offen.push(`${bereich(i, bis)}: ${text}`) + i = bis + 1 + } + if (zu.length) offen.push(`Geschlossen: ${zu.join(', ')}`) + return offen.map((zeile) => `${vorsatz}${zeile}`) +} + +const GAESTE_SIND = { verbraucher: 'Privatpersonen', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' } +const BUCHUNGSART_TEXT = { sofort: 'sofort buchbar', anfrage: 'auf Anfrage', gemischt: 'gemischt, je Leistung' } + +/** + * Zusammenfassung der Angaben fuer „Pruefen und bestellen“: acht Gruppen in der Reihenfolge der Schritte, jede { schritt, titel, zeilen }, + * die Zeilen nur Text (der Aufrufer setzt sie per textContent; Kundentext bleibt unveraendert, lange Wuensche sind gekuerzt). Es kommt nur vor, + * was angegeben ist; Fehlendes nennt bestellpruefung des Servers. Das Bundesland steht nur, wenn der Kunde es ausdruecklich gewaehlt hat + * (fragebogen.bundeslandGewaehlt: der Serverstandard RP ist keine Wahl). Nie feedToken oder IDs. branchen = Liste { key, name } aus GET /einstellungen. + */ +export function zusammenfassung(einstellung, fragebogen, branchen) { + const e = istObjekt(einstellung) ? einstellung : {} + const f = istObjekt(fragebogen) ? fragebogen : {} + const betrieb = istObjekt(e.betrieb) ? e.betrieb : {} + const ressourcen = (Array.isArray(e.ressourcen) ? e.ressourcen : []).filter(istObjekt) + const leistungen = (Array.isArray(e.leistungen) ? e.leistungen : []).filter(istObjekt) + const regeln = istObjekt(e.regeln) ? e.regeln : {} + const gastFelder = istObjekt(e.gastFelder) ? e.gastFelder : {} + const erinnerung = istObjekt(e.erinnerung) ? e.erinnerung : {} + const recht = istObjekt(e.recht) ? e.recht : {} + const einbau = istObjekt(f.einbau) ? f.einbau : {} + const gruppen = Object.fromEntries(FRAGEBOGEN_SCHRITTE.map(([key]) => [key, []])) + // zeile(schritt, text): nur ein nicht-leerer Text kommt in die Gruppe (so fehlt eine Zeile, wenn `angabe && …` falsch ist) + const zeile = (schritt, text) => { if (typeof text === 'string' && text !== '') gruppen[schritt].push(text) } + const bundesland = f.bundeslandGewaehlt === true ? wahlName(BUNDESLAENDER, e.bundesland) : undefined + const guteWahl = (wahl, key) => wahlName(wahl, key) !== undefined + + // 1. Branche und Betrieb + const branche = (Array.isArray(branchen) ? branchen : []).find((b) => istObjekt(b) && b.key === f.branche) + zeile('branche', istText(branche?.name) && `Branche: ${branche.name}`) + zeile('branche', istText(betrieb.name) && `Betrieb: ${betrieb.name}`) + zeile('branche', istText(betrieb.adresse) && `Anschrift: ${betrieb.adresse}`) + + // 2. Wer wird gebucht: Personen und Plaetze getrennt, in der Reihenfolge ihres ersten Auftretens + const nachTyp = new Map() + for (const r of ressourcen.filter((x) => istText(x.name))) { + const wort = r.typ === 'platz' ? 'Plätze' : 'Personen' + if (!nachTyp.has(wort)) nachTyp.set(wort, []) + nachTyp.get(wort).push(`${r.name}${r.aktiv === false ? ' (nicht aktiv)' : ''}`) + } + for (const [wort, namen] of nachTyp) zeile('wer', `${wort}: ${namen.join(', ')}`) + + // 3. Leistungen + for (const l of leistungen.filter((x) => istText(x.name))) { + zeile('leistungen', `${l.name}: ${Number.isInteger(l.dauer) ? `${l.dauer} Min., ` : ''}${preisAnzeige(l)}${l.aktiv === false ? ' (nicht aktiv)' : ''}`) + } + zeile('leistungen', leistungen.some((l) => preisAnzeige(l) !== 'ohne Preis') && `Preise geprüft: ${f.preiseGeprueft === true ? 'ja' : 'nein'}`) + + // 4. Zeiten + const aktive = ressourcen.filter((r) => r.aktiv !== false) + for (const r of aktive) for (const text of wochenplanZeilen(r.plan, aktive.length > 1 && istText(r.name) ? `${r.name}: ` : '')) zeile('zeiten', text) + zeile('zeiten', typeof e.feiertageGeschlossen === 'boolean' && `Feiertage: ${e.feiertageGeschlossen ? 'geschlossen' : 'geöffnet'}`) + zeile('zeiten', bundesland !== undefined && `Bundesland: ${bundesland}`) + const abwesend = Array.isArray(e.abwesenheiten) ? e.abwesenheiten.length : 0 + zeile('zeiten', abwesend > 0 && `Abwesenheiten: ${abwesend} eingetragen`) + const sonder = istObjekt(e.sonderoeffnungen) ? Object.keys(e.sonderoeffnungen).length : 0 + zeile('zeiten', sonder > 0 && `Besondere Öffnungstage: ${sonder}`) + + // 5. Regeln + zeile('regeln', leistungen.length > 0 && `Buchungsart: ${BUCHUNGSART_TEXT[buchungsartLage(leistungen)]}`) + zeile('regeln', Number.isInteger(regeln.raster) && `Raster: ${regeln.raster} Minuten`) + zeile('regeln', Number.isInteger(regeln.vorlaufMin) && regeln.vorlaufMin >= 0 && `Vorlaufzeit: ${zeitspanne(regeln.vorlaufMin)}`) + zeile('regeln', Number.isInteger(regeln.horizontTage) && `Buchungszeitraum: ${einheit(regeln.horizontTage, 'Tag', 'Tage')} im Voraus`) + zeile('regeln', Number.isInteger(regeln.absageBisStd) && `Absage durch Gäste: bis ${einheit(regeln.absageBisStd, 'Stunde', 'Stunden')} vorher`) + zeile('regeln', Number.isInteger(regeln.maxOffeneJeEmail) && `Offene Termine je E-Mail-Adresse: höchstens ${regeln.maxOffeneJeEmail}`) + zeile('regeln', typeof regeln.personWaehlbar === 'boolean' && `${ressourcenWort(ressourcen)} wählbar: ${regeln.personWaehlbar ? 'ja' : 'nein'}`) + zeile('regeln', Object.hasOwn(GAESTE_SIND, recht.gaesteSind) && `Deine Gäste sind: ${GAESTE_SIND[recht.gaesteSind]}`) + zeile('regeln', recht.freizeit === true && 'Leistungen sind Freizeitangebote mit festem Termin') + const arten = (istObjekt(e.zahlung) && Array.isArray(e.zahlung.vorOrtArten) ? e.zahlung.vorOrtArten : []).map((a) => wahlName(ZAHLARTEN, a)).filter(Boolean) + zeile('regeln', arten.length > 0 && `Zahlung: vor Ort (${arten.join(', ')})`) + zeile('regeln', istObjekt(e.storno) && `Stornobedingungen: ${istText(e.storno.text) ? 'Text hinterlegt' : 'kein Text'}`) + + // 6. Gastfelder + zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.telefon) && `Telefonnummer des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.telefon)}`) + zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.nachricht) && `Nachricht des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.nachricht)}`) + for (const z of (Array.isArray(gastFelder.zusatz) ? gastFelder.zusatz : []).filter((x) => istObjekt(x) && istText(x.label))) { + zeile('gastfelder', `Zusatzfeld „${z.label}“: ${z.pflicht === true ? 'Pflicht' : 'Optional'}${z.typ === 'auswahl' ? ' (Auswahl)' : ''}`) + } + + // 7. Benachrichtigung + zeile('benachrichtigung', istText(betrieb.email) && `Benachrichtigungen an: ${betrieb.email}`) + zeile('benachrichtigung', guteWahl(ANREDE_WAHL, e.anrede) && `Anrede der Gäste: ${wahlName(ANREDE_WAHL, e.anrede)}`) + if (typeof erinnerung.anbieten === 'boolean') { + zeile('benachrichtigung', erinnerung.anbieten ? `E-Mail-Erinnerung für Gäste: angeboten, ${einheit(erinnerung.stundenVorher, 'Stunde', 'Stunden')} vorher` : 'E-Mail-Erinnerung für Gäste: aus') + } + + // 8. Website und Einbau + zeile('website', istText(betrieb.telefon) && `Telefon: ${betrieb.telefon}`) + zeile('website', istText(betrieb.impressumUrl) && `Impressum: ${betrieb.impressumUrl}`) + zeile('website', istText(betrieb.datenschutzUrl) && `Datenschutzerklärung: ${betrieb.datenschutzUrl}`) + zeile('website', istText(betrieb.agbUrl) && `AGB: ${betrieb.agbUrl}`) + zeile('website', istText(betrieb.websiteUrl) && `Website: ${betrieb.websiteUrl}`) + zeile('website', einbau.modus === 'inline' && 'Darstellung: direkt auf deiner Seite') + zeile('website', einbau.modus === 'knopf' && `Darstellung: hinter einem Knopf${istText(einbau.knopfText) ? ` „${einbau.knopfText}“` : ''}`) + for (const [label, wert] of [['Stelle', einbau.stelle], ['Gestaltungswunsch', einbau.gestaltung], ['Bemerkung', einbau.bemerkung]]) { + zeile('website', istText(wert) && `${label}: ${kurzText(wert, 200)}`) + } + + return FRAGEBOGEN_SCHRITTE.map(([schritt, titel]) => ({ schritt, titel, zeilen: gruppen[schritt] })) +} diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js new file mode 100644 index 0000000..e290fe8 --- /dev/null +++ b/test/termineFragebogenView.test.js @@ -0,0 +1,455 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import * as ansicht from '../public/termine-view.js' +import { + FRAGEBOGEN_SCHRITTE, LIMITS, MAX, buchungsartFuerAlle, buchungsartLage, ersterOffenerSchritt, ressourcenAusNamen, schrittStatus, zusammenfassung, +} from '../public/termine-view.js' +import { BRANCHEN, SCHRITTE, SCHRITT_TITEL, leerFragebogen } from '../server/services/termine/fragebogen.js' +import { normalisiereEinstellung } from '../server/services/termine/einstellungen.js' +import { ausVorlage } from '../server/services/termine/vorlagen.js' + +// Reine Helfer des Fragebogens in public/termine-view.js (TP2, Task C7a): Schrittstand, Namen der Personen/Plaetze, sofort/Anfrage fuer alle +// und die Zusammenfassung auf der Bestellseite. Kein DOM; der Spiegel zum Server (SCHRITTE) steht in test/termineView.test.js. + +const JETZT = new Date('2026-10-05T08:00:00.000Z') +const BETRIEB = { + name: 'Salon Muster', + email: 'salon@example.invalid', + telefon: '06301 12345', + adresse: 'Hauptstraße 1, 67655 Kaiserslautern', + impressumUrl: 'https://salon.example.invalid/impressum', + datenschutzUrl: 'https://salon.example.invalid/datenschutz', + bundesland: 'RP', +} +const salon = () => ausVorlage('salon', BETRIEB, JETZT) + +/** Zaehlende neueId: r_test0001, r_test0002 … (bekommt die belegten IDs, braucht sie hier nicht). */ +const zaehler = () => { + let n = 0 + return () => `r_test${String(++n).padStart(4, '0')}` +} +/** Zwei Ressourcen mit verschiedenen Plaenen, wie sie ein Entwurf haben kann. */ +const zweiRessourcen = () => [ + { id: 'r_anna0001', typ: 'person', name: 'Anna', aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: 'a'.repeat(24) }, + { id: 'r_ben00002', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['13:00', '17:00']] }, feedToken: 'b'.repeat(24) }, +] +const namenVon = (ressourcen) => ressourcen.map((r) => r.name) +const idsVon = (ressourcen) => ressourcen.map((r) => r.id) + +// ---------------------------------------------------------------- Schritte + +test('FRAGEBOGEN_SCHRITTE: acht Paare [Schlüssel, Titel], Schlüssel und Titel wie beim Server', () => { + assert.equal(FRAGEBOGEN_SCHRITTE.length, 8) + for (const eintrag of FRAGEBOGEN_SCHRITTE) { + assert.equal(eintrag.length, 2) + assert.equal(typeof eintrag[0], 'string') + assert.equal(typeof eintrag[1], 'string') + assert.ok(eintrag[1].length > 0) + } + assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([key]) => key), [...SCHRITTE]) + assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([, titel]) => titel), SCHRITTE.map((key) => SCHRITT_TITEL[key])) +}) + +test('schrittStatus: leerer Fragebogen ⇒ nichts erledigt, Reihenfolge und Titel wie FRAGEBOGEN_SCHRITTE', () => { + const erwartet = FRAGEBOGEN_SCHRITTE.map(([key, titel]) => ({ key, titel, erledigt: false })) + assert.deepEqual(schrittStatus(leerFragebogen()), erwartet) + assert.deepEqual(schrittStatus(null), erwartet, 'noch kein Fragebogen gespeichert') + assert.deepEqual(schrittStatus(undefined), erwartet) + assert.deepEqual(schrittStatus({ bestaetigt: 'wer' }), erwartet, 'kein Array ⇒ nichts erledigt') + assert.deepEqual(schrittStatus({}), erwartet) +}) + +test('schrittStatus: teilweise bestätigt, Fremdes zählt nicht', () => { + const status = schrittStatus({ bestaetigt: ['zeiten', 'branche', 'wer', 'bestellen', 'unbekannt'] }) + assert.deepEqual(status.map((s) => s.key), [...SCHRITTE]) + assert.deepEqual(status.filter((s) => s.erledigt).map((s) => s.key), ['branche', 'wer', 'zeiten']) + assert.equal(status.length, 8, '„bestellen“ ist kein Schritt des Fragebogens') +}) + +test('schrittStatus: vollständig bestätigt ⇒ alles erledigt', () => { + assert.ok(schrittStatus({ bestaetigt: [...SCHRITTE] }).every((s) => s.erledigt === true)) +}) + +test('ersterOffenerSchritt: leer ⇒ branche, teilweise ⇒ der erste Schritt ohne Haken, vollständig ⇒ bestellen', () => { + assert.equal(ersterOffenerSchritt(leerFragebogen()), 'branche') + assert.equal(ersterOffenerSchritt(null), 'branche') + assert.equal(ersterOffenerSchritt(undefined), 'branche') + assert.equal(ersterOffenerSchritt({ bestaetigt: ['branche', 'wer'] }), 'leistungen') + assert.equal(ersterOffenerSchritt({ bestaetigt: ['branche', 'leistungen'] }), 'wer', 'die erste Lücke zählt, nicht die Zahl der Haken') + assert.equal(ersterOffenerSchritt({ bestaetigt: SCHRITTE.slice(0, 7) }), 'website') + assert.equal(ersterOffenerSchritt({ bestaetigt: [...SCHRITTE] }), 'bestellen') +}) + +// ---------------------------------------------------------------- ressourcenAusNamen + +test('ressourcenAusNamen: „Stylist/in“ → „Anna“ behält ID und Plan, der zweite Name bekommt neue ID und den Plan von Anna', () => { + const bisher = salon().ressourcen + assert.equal(bisher.length, 1) + assert.equal(bisher[0].name, 'Stylist/in') + const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben'], neueId: zaehler() }) + assert.deepEqual(namenVon(erg.ressourcen), ['Anna', 'Ben']) + const [anna, ben] = erg.ressourcen + assert.equal(anna.id, bisher[0].id, 'Anna behält die ID') + assert.deepEqual(anna.plan, bisher[0].plan, 'und den Plan') + assert.equal(ben.id, 'r_test0001') + assert.deepEqual(ben.plan, anna.plan, 'Ben bekommt den Plan von Anna …') + assert.notEqual(ben.plan, anna.plan, '… als Kopie') + const laenge = anna.plan[2].length + ben.plan[2].push(['20:00', '21:00']) + assert.equal(anna.plan[2].length, laenge, 'Änderungen an Ben verändern Anna nicht') + assert.equal(bisher[0].plan[2].length, laenge, 'und nicht den alten Eintrag') + assert.deepEqual(erg.neu, ['r_test0001']) + assert.deepEqual(erg.entfernt, []) + assert.ok(erg.ressourcen.every((r) => r.aktiv === true && r.typ === 'person')) +}) + +test('ressourcenAusNamen: gleiche Namen behalten ID und Plan, auch in anderer Reihenfolge und Schreibweise', () => { + const bisher = zweiRessourcen() + const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['ben', 'Anna'], neueId: zaehler() }) + assert.deepEqual(idsVon(erg.ressourcen), ['r_ben00002', 'r_anna0001'], 'Reihenfolge der Namen, IDs bleiben') + assert.deepEqual(namenVon(erg.ressourcen), ['ben', 'Anna'], 'die eingegebene Schreibweise gilt') + assert.deepEqual(erg.ressourcen[0].plan, bisher[1].plan) + assert.deepEqual(erg.ressourcen[1].plan, bisher[0].plan) + assert.deepEqual(erg.neu, []) + assert.deepEqual(erg.entfernt, []) +}) + +test('ressourcenAusNamen: Entfernen liefert die IDs der weggefallenen Einträge', () => { + const bisher = [...zweiRessourcen(), { id: 'r_cem00003', typ: 'person', name: 'Cem', aktiv: true, plan: {} }] + const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben'], neueId: zaehler() }) + assert.deepEqual(idsVon(erg.ressourcen), ['r_ben00002']) + assert.deepEqual(erg.entfernt, ['r_anna0001', 'r_cem00003']) + assert.deepEqual(erg.neu, []) + const alle = ressourcenAusNamen({ bisher, typ: 'person', namen: [], neueId: zaehler() }) + assert.deepEqual(alle.ressourcen, []) + assert.deepEqual(alle.entfernt, ['r_anna0001', 'r_ben00002', 'r_cem00003']) +}) + +test('ressourcenAusNamen: übrige alte Einträge werden der Reihe nach umbenannt', () => { + const bisher = zweiRessourcen() + const umbenannt = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Xaver', 'Yvonne'], neueId: zaehler() }) + assert.deepEqual(idsVon(umbenannt.ressourcen), ['r_anna0001', 'r_ben00002']) + assert.deepEqual(umbenannt.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[1].plan]) + assert.deepEqual([umbenannt.neu, umbenannt.entfernt], [[], []]) + // Ben bleibt, Dora übernimmt Annas Platz in der Liste, Cem fällt weg + const mitCem = [...bisher, { id: 'r_cem00003', typ: 'person', name: 'Cem', aktiv: true, plan: { 3: [['08:00', '09:00']] } }] + const gemischt = ressourcenAusNamen({ bisher: mitCem, typ: 'person', namen: ['Ben', 'Dora'], neueId: zaehler() }) + assert.deepEqual(namenVon(gemischt.ressourcen), ['Ben', 'Dora']) + assert.deepEqual(idsVon(gemischt.ressourcen), ['r_ben00002', 'r_anna0001']) + assert.deepEqual(gemischt.ressourcen[1].plan, bisher[0].plan) + assert.deepEqual(gemischt.entfernt, ['r_cem00003']) + assert.deepEqual(gemischt.neu, []) +}) + +test('ressourcenAusNamen: trimmt, entfernt Leere und Doppelte ohne Rücksicht auf Groß-/Kleinschreibung', () => { + const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen: [' Anna ', '', ' ', 'anna', 'ANNA', 'Ben', ' Ben', null, undefined, 5], neueId: zaehler() }) + assert.deepEqual(namenVon(erg.ressourcen), ['Anna', 'Ben'], 'die erste Schreibweise gilt') + assert.equal(idsVon(erg.ressourcen).length, 2) + assert.deepEqual(namenVon(ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['Anna Müller', 'anna müller'] }).ressourcen), ['Anna Müller'], 'mehrfache Leerzeichen zählen als eines (wie beim Server)') +}) + +test('ressourcenAusNamen: höchstens LIMITS.ressourcen Einträge', () => { + const namen = Array.from({ length: LIMITS.ressourcen + 5 }, (_, i) => `Person ${i + 1}`) + const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen, neueId: zaehler() }) + assert.equal(LIMITS.ressourcen, 20) + assert.equal(erg.ressourcen.length, 20) + assert.deepEqual(namenVon(erg.ressourcen), namen.slice(0, 20), 'die ersten 20 bleiben') + assert.equal(erg.neu.length, 20) + // Leere und Doppelte zählen nicht mit + const mitLuecken = ['', ...namen.slice(0, 20).flatMap((n) => [n, n.toUpperCase()]), 'Überzählig'] + assert.deepEqual(namenVon(ressourcenAusNamen({ bisher: [], typ: 'person', namen: mitLuecken }).ressourcen), namen.slice(0, 20)) + // genau LIMITS.ressourcen bleibt ganz + assert.equal(ressourcenAusNamen({ bisher: [], typ: 'person', namen: namen.slice(0, 20) }).ressourcen.length, 20) +}) + +test('ressourcenAusNamen: Namen werden auf MAX.ressourcenName Zeichen gekürzt', () => { + const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['x'.repeat(MAX.ressourcenName + 20)] }) + assert.equal([...erg.ressourcen[0].name].length, MAX.ressourcenName) +}) + +test('ressourcenAusNamen: typ gilt für alle, auch für behaltene Einträge', () => { + const bisher = zweiRessourcen() + const erg = ressourcenAusNamen({ bisher, typ: 'platz', namen: ['Anna', 'Platz 2', 'Platz 3'], neueId: zaehler() }) + assert.ok(erg.ressourcen.every((r) => r.typ === 'platz')) + assert.equal(erg.ressourcen[0].id, 'r_anna0001') + assert.deepEqual(bisher.map((r) => r.typ), ['person', 'person'], 'bisher bleibt unverändert') + assert.ok(ressourcenAusNamen({ bisher, typ: 'person', namen: ['A'] }).ressourcen.every((r) => r.typ === 'person')) + assert.equal(ressourcenAusNamen({ bisher: [], typ: 'unsinn', namen: ['A'] }).ressourcen[0].typ, 'person', 'unbekannter Typ ⇒ Person') +}) + +test('ressourcenAusNamen: ändert die Eingaben nicht, liefert nur Felder, die der Server kennt (kein feedToken)', () => { + const bisher = zweiRessourcen() + const vorher = JSON.stringify(bisher) + const namen = ['Ben', ' Anna ', 'Cem'] + const namenVorher = [...namen] + const erg = ressourcenAusNamen({ bisher, typ: 'platz', namen, neueId: zaehler() }) + assert.equal(JSON.stringify(bisher), vorher) + assert.deepEqual(namen, namenVorher) + for (const r of erg.ressourcen) assert.deepEqual(Object.keys(r).sort(), ['aktiv', 'id', 'name', 'plan', 'typ']) + assert.ok(!JSON.stringify(erg).includes('feedToken')) + assert.deepEqual(ressourcenAusNamen({ bisher: null, typ: 'person', namen: ['A'], neueId: zaehler() }).entfernt, [], 'ohne bisher: nichts zu entfernen') + assert.deepEqual(ressourcenAusNamen({ bisher, typ: 'person', namen: 'Anna', neueId: zaehler() }).ressourcen, [], 'keine Liste ⇒ keine Namen') +}) + +test('ressourcenAusNamen: neue IDs sind gültig und kollidieren nicht (Vorgabe ohne neueId, Zähler ohne Treffer)', () => { + const bisher = zweiRessourcen() + const namen = Array.from({ length: 15 }, (_, i) => `Neu ${i}`) + const erg = ressourcenAusNamen({ bisher, typ: 'person', namen }) + const ids = idsVon(erg.ressourcen) + assert.equal(new Set(ids).size, ids.length) + assert.ok(ids.every((id) => /^r_[a-z0-9]{8}$/.test(id))) + assert.equal(erg.neu.length, 13, 'zwei der Namen übernehmen die alten Einträge') + assert.ok(erg.neu.every((id) => ids.includes(id) && !bisher.some((r) => r.id === id))) + // neueId bekommt die belegten IDs (Set) und darf nur unbelegte liefern + const gesehen = [] + ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben', 'Cem', 'Dora'], neueId: (belegt) => { gesehen.push([...belegt]); return `r_zzzz000${gesehen.length}` } }) + assert.equal(gesehen.length, 2) + assert.ok(gesehen[0].includes('r_anna0001') && gesehen[0].includes('r_ben00002'), 'behaltene IDs sind belegt') + assert.ok(gesehen[1].includes('r_zzzz0001'), 'und die schon vergebenen neuen') + // liefert neueId eine belegte ID, ist das ein Fehler (der Server würde doppelte IDs ablehnen) + assert.throws(() => ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben', 'Cem'], neueId: () => 'r_anna0001' }), /ID/) +}) + +test('ressourcenAusNamen: das Ergebnis besteht normalisiereEinstellung (Leistungen allen zugeordnet), für Personen und Plätze', () => { + for (const [typ, namen] of [['person', ['Anna', 'Ben', 'Cem']], ['platz', ['Platz 1', 'Platz 2']], ['person', ['Nur Ich']]]) { + const e = salon() + const erg = ressourcenAusNamen({ bisher: e.ressourcen, typ, namen }) + e.ressourcen = erg.ressourcen + e.leistungen = e.leistungen.map((l) => ({ ...l, ressourcen: idsVon(erg.ressourcen) })) + const geprueft = normalisiereEinstellung(e, { now: JETZT }) + assert.equal(geprueft.fehler, undefined, `${typ}: ${JSON.stringify(geprueft.fehler)}`) + assert.deepEqual(namenVon(geprueft.einstellung.ressourcen), namen) + assert.ok(geprueft.einstellung.ressourcen.every((r) => r.typ === typ && r.aktiv === true)) + assert.ok(geprueft.einstellung.ressourcen.every((r) => typeof r.feedToken === 'string' && r.feedToken.length > 0), 'der Server vergibt die Feed-Token') + } +}) + +// ---------------------------------------------------------------- Buchungsart + +test('buchungsartFuerAlle: ändert nur buchungsart, an einer Kopie', () => { + const leistungen = salon().leistungen + const vorher = JSON.stringify(leistungen) + const anfrage = buchungsartFuerAlle(leistungen, 'anfrage') + assert.equal(JSON.stringify(leistungen), vorher, 'Eingabe unverändert') + assert.equal(anfrage.length, leistungen.length) + assert.ok(anfrage.every((l) => l.buchungsart === 'anfrage')) + assert.deepEqual(anfrage.map(({ buchungsart, ...rest }) => rest), leistungen.map(({ buchungsart, ...rest }) => rest), 'alles andere bleibt') + assert.notEqual(anfrage[0], leistungen[0]) + const zurueck = buchungsartFuerAlle(anfrage, 'sofort') + assert.deepEqual(zurueck, leistungen) +}) + +test('buchungsartFuerAlle: inaktive Leistungen auch, unbekannte Art und Nicht-Liste ändern nichts', () => { + const leistungen = [{ id: 's_aaaaaaaa', name: 'A', aktiv: false, buchungsart: 'sofort' }, { id: 's_bbbbbbbb', name: 'B', aktiv: true, buchungsart: 'anfrage' }] + assert.deepEqual(buchungsartFuerAlle(leistungen, 'anfrage').map((l) => l.buchungsart), ['anfrage', 'anfrage']) + assert.deepEqual(buchungsartFuerAlle(leistungen, 'bar'), leistungen) + assert.deepEqual(buchungsartFuerAlle(leistungen, undefined), leistungen) + assert.deepEqual(buchungsartFuerAlle(null, 'sofort'), []) + assert.deepEqual(buchungsartFuerAlle(undefined, 'sofort'), []) +}) + +test('buchungsartLage: sofort, anfrage oder gemischt', () => { + const l = (...arten) => arten.map((buchungsart, i) => ({ id: `s_${i}`, buchungsart })) + assert.equal(buchungsartLage(l('sofort', 'sofort')), 'sofort') + assert.equal(buchungsartLage(l('anfrage', 'anfrage', 'anfrage')), 'anfrage') + assert.equal(buchungsartLage(l('sofort', 'anfrage')), 'gemischt') + assert.equal(buchungsartLage(l('anfrage')), 'anfrage') + assert.equal(buchungsartLage([]), 'sofort', 'ohne Leistungen gilt die Vorgabe des Servers') + assert.equal(buchungsartLage(null), 'sofort') + assert.equal(buchungsartLage([{ name: 'ohne Angabe' }, { buchungsart: 'anfrage' }]), 'gemischt', 'fehlende Angabe = sofort (Vorgabe des Servers)') + assert.equal(buchungsartLage(ausVorlage('handwerk', BETRIEB, JETZT).leistungen), 'anfrage', 'Vorlage mit Anfragen') + assert.equal(buchungsartLage(salon().leistungen), 'sofort') +}) + +test('buchungsartLage nach buchungsartFuerAlle: zeigt genau die gesetzte Art', () => { + const leistungen = [{ buchungsart: 'sofort' }, { buchungsart: 'anfrage' }] + assert.equal(buchungsartLage(leistungen), 'gemischt') + assert.equal(buchungsartLage(buchungsartFuerAlle(leistungen, 'anfrage')), 'anfrage') + assert.equal(buchungsartLage(buchungsartFuerAlle(leistungen, 'sofort')), 'sofort') +}) + +// ---------------------------------------------------------------- zusammenfassung + +const stand = (aendere = () => {}) => { + const einstellung = salon() + const fragebogen = { ...leerFragebogen(), branche: 'salon', bestaetigt: [...SCHRITTE], bundeslandGewaehlt: true, preiseGeprueft: true, einbau: { ...leerFragebogen().einbau, modus: 'inline' } } + aendere(einstellung, fragebogen) + return { einstellung, fragebogen } +} +const gruppe = (ergebnis, schritt) => ergebnis.find((g) => g.schritt === schritt) + +test('zusammenfassung: acht Gruppen in der Reihenfolge der Schritte, nur Text', () => { + const { einstellung, fragebogen } = stand() + const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN) + assert.deepEqual(erg.map((g) => g.schritt), [...SCHRITTE]) + assert.deepEqual(erg.map((g) => g.titel), FRAGEBOGEN_SCHRITTE.map(([, titel]) => titel)) + for (const g of erg) { + assert.deepEqual(Object.keys(g).sort(), ['schritt', 'titel', 'zeilen']) + assert.ok(Array.isArray(g.zeilen) && g.zeilen.length > 0, `${g.schritt}: Zeilen`) + assert.ok(g.zeilen.every((z) => typeof z === 'string' && z.trim() !== ''), `${g.schritt}: nur nicht-leere Texte`) + } +}) + +test('zusammenfassung (Vorlage salon): Name, Branche und Anschrift', () => { + const { einstellung, fragebogen } = stand() + const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'branche').zeilen + assert.ok(zeilen.includes('Branche: Friseur und Salon')) + assert.ok(zeilen.includes('Betrieb: Salon Muster')) + assert.ok(zeilen.includes('Anschrift: Hauptstraße 1, 67655 Kaiserslautern')) +}) + +test('zusammenfassung (Vorlage salon): Leistungen mit Preis', () => { + const { einstellung, fragebogen } = stand() + const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'leistungen').zeilen + assert.ok(zeilen.includes('Herrenschnitt: 30 Min., 28,00 €'), zeilen.join(' | ')) + assert.ok(zeilen.includes('Damenschnitt: 45 Min., ab 45,00 €')) + assert.ok(zeilen.includes('Kinderschnitt: 30 Min., 18,00 €')) + assert.ok(zeilen.includes('Färben: 120 Min., ab 75,00 €')) + assert.ok(zeilen.includes('Preise geprüft: ja')) + const offen = stand((e, f) => { f.preiseGeprueft = false }) + assert.ok(gruppe(zusammenfassung(offen.einstellung, offen.fragebogen, BRANCHEN), 'leistungen').zeilen.includes('Preise geprüft: nein')) + const ohnePreis = stand((e) => { e.leistungen[0] = { ...e.leistungen[0], preisArt: 'keiner', preisCent: 0, aktiv: false } }) + const z = gruppe(zusammenfassung(ohnePreis.einstellung, ohnePreis.fragebogen, BRANCHEN), 'leistungen').zeilen + assert.ok(z.includes('Herrenschnitt: 30 Min., ohne Preis (nicht aktiv)'), z.join(' | ')) +}) + +test('zusammenfassung (Vorlage salon): Wochenplan, Tage mit gleichen Zeiten zusammengefasst, Ruhetage', () => { + const { einstellung, fragebogen } = stand() + const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'zeiten').zeilen + assert.ok(zeilen.includes('Dienstag–Freitag: 09:00–12:30, 13:30–18:00'), zeilen.join(' | ')) + assert.ok(zeilen.includes('Samstag: 09:00–13:00')) + assert.ok(zeilen.includes('Geschlossen: Montag, Sonntag')) + const mehrere = stand((e) => { e.ressourcen = [{ ...e.ressourcen[0], name: 'Anna' }, { ...e.ressourcen[0], id: 'r_zweite01', name: 'Ben', plan: { 1: [['10:00', '11:00']] } }] }) + const z = gruppe(zusammenfassung(mehrere.einstellung, mehrere.fragebogen, BRANCHEN), 'zeiten').zeilen + assert.ok(z.includes('Anna: Dienstag–Freitag: 09:00–12:30, 13:30–18:00'), z.join(' | ')) + assert.ok(z.includes('Ben: Montag: 10:00–11:00')) + assert.ok(z.includes('Ben: Geschlossen: Dienstag–Sonntag')) +}) + +test('zusammenfassung: wer wird gebucht, Zeitraum-Angaben (Feiertage, Urlaub, besondere Tage)', () => { + const { einstellung, fragebogen } = stand((e) => { + e.ressourcen = [{ id: 'r_aaaaaaaa', typ: 'person', name: 'Anna', aktiv: true, plan: {} }, { id: 'r_bbbbbbbb', typ: 'person', name: 'Ben', aktiv: false, plan: {} }] + e.abwesenheiten = [{ id: 'a_aaaaaaaa', ressourceId: null, von: '2027-01-01', bis: '2027-01-02', titel: '' }] + e.sonderoeffnungen = { '2026-12-24': [['09:00', '12:00']], '2026-12-31': [] } + e.feiertageGeschlossen = true + }) + const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN) + assert.deepEqual(gruppe(erg, 'wer').zeilen, ['Personen: Anna, Ben (nicht aktiv)']) + const zeiten = gruppe(erg, 'zeiten').zeilen + assert.ok(zeiten.includes('Feiertage: geschlossen')) + assert.ok(zeiten.includes('Abwesenheiten: 1 eingetragen')) + assert.ok(zeiten.includes('Besondere Öffnungstage: 2')) + const plaetze = stand((e) => { e.ressourcen = [{ id: 'r_aaaaaaaa', typ: 'platz', name: 'Platz 1', aktiv: true, plan: {} }, { id: 'r_cccccccc', typ: 'person', name: 'Cem', aktiv: true, plan: {} }] }) + assert.deepEqual(gruppe(zusammenfassung(plaetze.einstellung, plaetze.fragebogen, BRANCHEN), 'wer').zeilen, ['Plätze: Platz 1', 'Personen: Cem']) +}) + +test('zusammenfassung: Bundesland nur, wenn der Kunde es ausdrücklich gewählt hat (der Serverstandard RP zählt nicht)', () => { + const nichtGewaehlt = stand((e, f) => { f.bundeslandGewaehlt = false }) + assert.equal(nichtGewaehlt.einstellung.bundesland, 'RP', 'Serverstandard') + const ohne = JSON.stringify(zusammenfassung(nichtGewaehlt.einstellung, nichtGewaehlt.fragebogen, BRANCHEN)) + assert.ok(!ohne.includes('Rheinland-Pfalz') && !ohne.includes('Bundesland'), ohne) + const gewaehlt = stand((e) => { e.bundesland = 'HE' }) + const zeilen = gruppe(zusammenfassung(gewaehlt.einstellung, gewaehlt.fragebogen, BRANCHEN), 'zeiten').zeilen + assert.ok(zeilen.includes('Bundesland: Hessen'), zeilen.join(' | ')) + const rp = stand() + assert.ok(gruppe(zusammenfassung(rp.einstellung, rp.fragebogen, BRANCHEN), 'zeiten').zeilen.includes('Bundesland: Rheinland-Pfalz'), 'RP ausdrücklich gewählt') +}) + +test('zusammenfassung: Regeln, Gastfelder, Benachrichtigung und Website', () => { + const { einstellung, fragebogen } = stand((e, f) => { + e.gastFelder = { telefon: 'pflicht', nachricht: 'optional', zusatz: [{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', optionen: [], pflicht: true }, { key: 'wahl', label: 'Wunsch', typ: 'auswahl', optionen: ['a'], pflicht: false }] } + e.anrede = 'du' + e.erinnerung = { anbieten: true, stundenVorher: 12 } + e.betrieb.websiteUrl = 'https://salon.example.invalid' + f.einbau = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Unter dem Text auf der Startseite', gestaltung: 'Dunkelblau', bemerkung: 'Bitte vor dem Wochenende' } + }) + const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN) + const regeln = gruppe(erg, 'regeln').zeilen + assert.ok(regeln.includes('Buchungsart: sofort buchbar'), regeln.join(' | ')) + assert.ok(regeln.includes('Raster: 15 Minuten')) + assert.ok(regeln.includes('Vorlaufzeit: 2 Stunden')) + assert.ok(regeln.includes('Buchungszeitraum: 60 Tage im Voraus')) + assert.ok(regeln.includes('Absage durch Gäste: bis 24 Stunden vorher')) + assert.ok(regeln.includes('Deine Gäste sind: Privatpersonen')) + assert.ok(regeln.includes('Zahlung: vor Ort (bar, EC-Karte, Kreditkarte)')) + const gast = gruppe(erg, 'gastfelder').zeilen + assert.ok(gast.includes('Telefonnummer des Gastes: Pflicht') && gast.includes('Nachricht des Gastes: Optional'), gast.join(' | ')) + assert.ok(gast.includes('Zusatzfeld „Kennzeichen“: Pflicht') && gast.includes('Zusatzfeld „Wunsch“: Optional (Auswahl)')) + const benachrichtigung = gruppe(erg, 'benachrichtigung').zeilen + assert.ok(benachrichtigung.includes('Benachrichtigungen an: salon@example.invalid'), benachrichtigung.join(' | ')) + assert.ok(benachrichtigung.includes('Anrede der Gäste: Du (persönlich)')) + assert.ok(benachrichtigung.includes('E-Mail-Erinnerung für Gäste: angeboten, 12 Stunden vorher')) + const website = gruppe(erg, 'website').zeilen + assert.ok(website.includes('Telefon: 06301 12345'), website.join(' | ')) + assert.ok(website.includes('Impressum: https://salon.example.invalid/impressum')) + assert.ok(website.includes('Datenschutzerklärung: https://salon.example.invalid/datenschutz')) + assert.ok(website.includes('Website: https://salon.example.invalid')) + assert.ok(website.includes('Darstellung: hinter einem Knopf „Termin buchen“')) + assert.ok(website.includes('Stelle: Unter dem Text auf der Startseite')) + assert.ok(website.includes('Gestaltungswunsch: Dunkelblau')) + assert.ok(website.includes('Bemerkung: Bitte vor dem Wochenende')) + const inline = stand() + assert.ok(gruppe(zusammenfassung(inline.einstellung, inline.fragebogen, BRANCHEN), 'website').zeilen.includes('Darstellung: direkt auf deiner Seite')) +}) + +test('zusammenfassung: Anfrage, gemischte Gäste, Freizeitangebote, keine Erinnerung', () => { + const { einstellung, fragebogen } = stand((e) => { + e.leistungen = e.leistungen.map((l, i) => ({ ...l, buchungsart: i === 0 ? 'anfrage' : 'sofort' })) + e.recht = { gaesteSind: 'gemischt', freizeit: true } + e.erinnerung = { anbieten: false, stundenVorher: 24 } + }) + const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN) + const regeln = gruppe(erg, 'regeln').zeilen + assert.ok(regeln.includes('Buchungsart: gemischt, je Leistung'), regeln.join(' | ')) + assert.ok(regeln.includes('Deine Gäste sind: Privatpersonen und Unternehmen')) + assert.ok(regeln.includes('Leistungen sind Freizeitangebote mit festem Termin')) + assert.ok(gruppe(erg, 'benachrichtigung').zeilen.includes('E-Mail-Erinnerung für Gäste: aus')) + const anfrage = stand((e) => { e.leistungen = ausVorlage('handwerk', BETRIEB, JETZT).leistungen }) + assert.ok(gruppe(zusammenfassung(anfrage.einstellung, anfrage.fragebogen, BRANCHEN), 'regeln').zeilen.includes('Buchungsart: auf Anfrage')) +}) + +test('zusammenfassung: nie feedToken, nie Zeichen aus der Einstellung außerhalb der genannten Felder', () => { + const { einstellung, fragebogen } = stand() + assert.ok(einstellung.ressourcen.every((r) => typeof r.feedToken === 'string' && r.feedToken.length > 0), 'die Einstellung trägt Token') + const text = JSON.stringify(zusammenfassung(einstellung, fragebogen, BRANCHEN)) + assert.ok(!text.includes('feedToken')) + for (const r of einstellung.ressourcen) assert.ok(!text.includes(r.feedToken)) + assert.ok(!text.includes(einstellung.ressourcen[0].id), 'auch keine IDs') +}) + +test('zusammenfassung: Kundentext bleibt Text (nichts entschärft, nichts verändert), lange Wünsche werden gekürzt', () => { + const frech = ' & "Anführung"' + const { einstellung, fragebogen } = stand((e, f) => { + e.betrieb.name = frech + f.einbau = { modus: 'inline', knopfText: '', stelle: '', gestaltung: 'g'.repeat(1000), bemerkung: '' } + }) + const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN) + assert.ok(gruppe(erg, 'branche').zeilen.includes(`Betrieb: ${frech}`)) + const wunsch = gruppe(erg, 'website').zeilen.find((z) => z.startsWith('Gestaltungswunsch: ')) + assert.ok(wunsch.length <= 'Gestaltungswunsch: '.length + 200, 'gekürzt') + assert.ok(wunsch.endsWith('…')) +}) + +test('zusammenfassung: ohne Einstellung und Fragebogen weiterhin acht Gruppen, ohne Fehler und ohne erfundene Angaben', () => { + for (const [e, f, b] of [[null, null, null], [undefined, undefined, undefined], [{}, {}, []], ['x', 5, 'y']]) { + const erg = zusammenfassung(e, f, b) + assert.deepEqual(erg.map((g) => g.schritt), [...SCHRITTE]) + assert.ok(erg.every((g) => Array.isArray(g.zeilen) && g.zeilen.every((z) => typeof z === 'string'))) + const text = JSON.stringify(erg) + assert.ok(!/undefined|null|NaN|Rheinland/.test(text), text) + } + const unbekannt = zusammenfassung(salon(), { ...leerFragebogen(), branche: 'unbekannt' }, BRANCHEN) + assert.ok(!gruppe(unbekannt, 'branche').zeilen.some((z) => z.startsWith('Branche:')), 'unbekannte Branche ⇒ keine Zeile') +}) + +test('Löschdatum und Abschalt-Texte gehören nicht in die Helfer des Fragebogens (E11: abschaltText)', () => { + assert.equal(ansicht.loeschDatum, undefined) + assert.equal(typeof ansicht.abschaltText, 'function') +}) + +test('termine-view.js bleibt rein: kein DOM, kein Netz, nur der Import der Anrede-Auswahl', () => { + const quelle = readFileSync(new URL('../public/termine-view.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') + assert.doesNotMatch(quelle, /\b(document|window|navigator|fetch|XMLHttpRequest|localStorage|sessionStorage)\b/) + assert.deepEqual(quelle.match(/^import .*$/gm), ["import { ANREDE_WAHL } from './termine-gaestetexte.js'"]) +}) diff --git a/test/termineView.test.js b/test/termineView.test.js index ab3894d..0e2d0d3 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync } from 'node:fs' import { - FILTER, ABSAGE_GRUENDE, abschaltText, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, + FILTER, FRAGEBOGEN_SCHRITTE, ABSAGE_GRUENDE, abschaltText, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel, } from '../public/termine-view.js' import { @@ -15,6 +15,7 @@ import { beschrifteFehler } from '../public/termine-speichern.js' import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js' import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js' import { VORLAGEN, ausVorlage } from '../server/services/termine/vorlagen.js' +import { SCHRITTE, SCHRITT_TITEL } from '../server/services/termine/fragebogen.js' const JETZT_SERVER = new Date('2026-10-05T08:00:00.000Z') // feste Uhr für Aufrufe der Server-Prüfung @@ -598,6 +599,11 @@ test('Spiegel: Obergrenzen der Listen sind die des Servers', () => { } }) +test('Spiegel: Schritte des Fragebogens (Schlüssel, Reihenfolge, Titel) sind die des Servers', () => { + assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([key]) => key), [...SCHRITTE]) + assert.deepEqual(Object.fromEntries(FRAGEBOGEN_SCHRITTE), Object.fromEntries(SCHRITTE.map((key) => [key, SCHRITT_TITEL[key]])), 'Titel wie in „Bitte bestätige den Schritt …“') +}) + test('Spiegel: Längen der Eingabefelder (maxlength) kürzt der Server nicht mehr', () => { const lang = (n) => 'x'.repeat(n) const lies = (e, was) => was(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung)