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' import { installFakeDom } from './support/fakeDom.js' // Namensraeume statt benannter Importe: fehlt ein Export, scheitert nur der Test, der ihn braucht (nicht die ganze Datei) import * as einstellungenTab from '../public/termine-tab-einstellungen.js' import * as teamTab from '../public/termine-tab-team.js' import * as huelle from '../public/termine.js' import { FENSTER_FEHLER } from '../public/termine-view.js' import { h, request } from '../public/termine-dom.js' import { neuerSpeicher } from '../public/termine-speichern.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: ein neuer Name vor einem behaltenen bekommt trotzdem dessen Plan (Vorlage unabhängig von der Reihenfolge)', () => { const bisher = [{ id: 'r_anna0001', typ: 'person', name: 'Anna', aktiv: true, plan: { 1: [['09:00', '12:00']] } }] const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Anna'], neueId: zaehler() }) assert.deepEqual(namenVon(erg.ressourcen), ['Ben', 'Anna']) const [ben, anna] = erg.ressourcen assert.equal(anna.id, 'r_anna0001', 'Anna behält ihre ID') assert.deepEqual(anna.plan, { 1: [['09:00', '12:00']] }, 'und ihren Plan') assert.equal(ben.id, 'r_test0001') assert.deepEqual(ben.plan, { 1: [['09:00', '12:00']] }, 'Ben bekommt Annas Plan, nicht einen leeren') assert.notEqual(ben.plan, anna.plan, 'als Kopie') ben.plan[1].push(['14:00', '16:00']) assert.equal(anna.plan[1].length, 1, 'Änderungen an Ben verändern Anna nicht') assert.equal(bisher[0].plan[1].length, 1, 'und nicht den alten Eintrag') assert.deepEqual(erg.neu, ['r_test0001']) assert.deepEqual(erg.entfernt, []) // mehrere neue Namen vor dem behaltenen, und ein umbenannter alter Eintrag liefert die Vorlage ebenso const drei = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Cem', 'Anna'], neueId: zaehler() }) assert.deepEqual(drei.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[0].plan, bisher[0].plan]) const umbenannt = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Cem'], neueId: zaehler() }) assert.deepEqual(idsVon(umbenannt.ressourcen), ['r_anna0001', 'r_test0001'], 'Anna wird zu Ben, Cem ist neu') assert.deepEqual(umbenannt.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[0].plan]) // ohne bisherigen Eintrag gibt es keine Vorlage: leerer Plan assert.deepEqual(ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['Ben', 'Cem'], neueId: zaehler() }).ressourcen.map((r) => r.plan), [{}, {}]) }) 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'"]) }) // ------------------------------------------------------- Fake-DOM (Testhilfe, TP2 C7b) // test/support/fakeDom.js tragen auch C8, C9a, C9b und C10b: die Tests hier halten fest, worauf sie sich verlassen. /** Fake-DOM fuer die Dauer von fn aufbauen (und immer abbauen). */ const mitDom = (optionen, fn) => async () => { const fx = installFakeDom(optionen) try { await fn(fx) } finally { fx.restore() } } /** Namen (data-feld) aller Formularfelder im Baum, in Dokumentreihenfolge. */ const felderVon = (fx, baum) => fx.alle(baum, (n) => typeof n.dataset.feld === 'string').map((n) => n.dataset.feld) test('Fake-DOM: globale Objekte nur zwischen installFakeDom und restore', () => { const vorher = { document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window } const fx = installFakeDom() assert.equal(globalThis.document, fx.document) assert.notEqual(globalThis.fetch, vorher.fetch) assert.equal(globalThis.CSS.escape('leistungen.2.dauer'), 'leistungen\\.2\\.dauer') fx.restore() assert.deepEqual({ document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }, vorher) }) test('Fake-DOM: Baum, Attribute, Eigenschaften und isConnected', mitDom(undefined, (fx) => { const knopf = h('button', { type: 'button', class: 'kb-btn tm-x', 'data-aktion': 'weiter', hidden: true }, 'Weiter ', h('b', null, 'jetzt'), 5) assert.equal(knopf.tagName, 'BUTTON') assert.equal(knopf.className, 'kb-btn tm-x') assert.equal(knopf.dataset.aktion, 'weiter') assert.equal(knopf.getAttribute('data-aktion'), 'weiter') assert.equal(knopf.hidden, true) assert.equal(fx.text(knopf), 'Weiter jetzt5') assert.equal(knopf.children.length, 1, 'children: nur Elemente') assert.equal(knopf.childNodes.length, 3) const wurzel = h('div', null, knopf) assert.equal(knopf.isConnected, false) document.body.append(wurzel) assert.equal(knopf.isConnected, true) wurzel.replaceChildren('neu') assert.equal(knopf.isConnected, false) assert.equal(knopf.parentNode, null) wurzel.textContent = '' assert.equal(wurzel.childNodes.length, 0) })) test('Fake-DOM: Selektoren wie die Client-Dateien sie brauchen', mitDom(undefined, (fx) => { const zeile = h('li', { class: 'tm-eintrag', 'data-id': 'r_abc' }, h('button', { 'data-aktion': 'bearbeiten' }, 'Bearbeiten'), h('button', { disabled: true }, 'Aus')) const feld = h('p', { 'data-fehler': 'leistungen.2.dauer' }) const sperre = h('fieldset', { disabled: true }, h('input', { type: 'text' })) const tafel = h('div', { role: 'tabpanel' }, h('ul', null, zeile), feld, sperre) document.body.append(tafel) assert.equal(tafel.querySelector('li[data-id="r_abc"] [data-aktion="bearbeiten"]').textContent, 'Bearbeiten') assert.equal(tafel.querySelector(`[data-fehler="${CSS.escape('leistungen.2.dauer')}"]`), feld) assert.deepEqual(zeile.querySelectorAll('button:not(:disabled)').map((n) => n.textContent), ['Bearbeiten']) assert.equal(zeile.querySelectorAll('button:disabled').length, 1) assert.equal(sperre.querySelector('input:not(:disabled)'), null, 'fieldset disabled sperrt die Felder darin') assert.equal(zeile.closest('[role="tabpanel"]'), tafel) assert.equal(zeile.closest('form'), null) assert.deepEqual(fx.alle(tafel, 'li, p').map((n) => n.tagName), ['LI', 'P']) assert.equal(fx.finde(tafel, (n) => n.tagName === 'FIELDSET'), sperre) assert.equal(tafel.querySelector('ul > li'), zeile) assert.throws(() => tafel.querySelector('a:hover'), /nicht unterstuetzt/) })) test('Fake-DOM: value, checked und selected wie im Browser', mitDom(undefined, async (fx) => { const auswahl = h('select', null, h('option', { value: '' }, '– bitte wählen –'), h('option', { value: 'RP' }, 'Rheinland-Pfalz'), h('option', { value: 'SL', selected: true }, 'Saarland')) assert.equal(auswahl.value, 'SL', 'selected-Attribut gilt') const ohneWahl = h('select', null, h('option', { value: '' }, '–'), h('option', { value: 'RP' }, 'RP')) assert.equal(ohneWahl.value, '', 'ohne selected zeigt der Browser die erste Option') await fx.setze(ohneWahl, 'RP') assert.equal(ohneWahl.value, 'RP') const text = h('input', { type: 'text', value: 'alt' }) assert.equal(text.value, 'alt') await fx.setze(text, 'neu') assert.equal(text.value, 'neu') assert.equal(text.getAttribute('value'), 'alt', 'die Eigenschaft ändert das Attribut nicht') const haken = h('input', { type: 'checkbox', checked: true }) assert.equal(haken.checked, true) await fx.klick(haken) assert.equal(haken.checked, false) const area = h('textarea', null, 'Vorgabe') assert.equal(area.value, 'Vorgabe') const a = h('input', { type: 'radio', name: 'x', checked: true }) const b = h('input', { type: 'radio', name: 'x' }) h('div', null, a, b) await fx.klick(b) assert.deepEqual([a.checked, b.checked], [false, true], 'Radios einer Gruppe schließen sich aus') })) test('Fake-DOM: Ereignisse steigen auf, klick auf Absenden-Knopf sendet das Formular, gesperrte Knöpfe tun nichts, async-Handler werden abgewartet', mitDom(undefined, async (fx) => { const log = [] const knopf = h('button', { type: 'submit' }, 'Weiter') const aus = h('button', { type: 'submit', disabled: true }, 'Aus') const lauf = h('button', { type: 'button' }, 'Lauf') const form = h('form', null, knopf, aus, lauf) form.addEventListener('submit', async (ev) => { ev.preventDefault() log.push('submit') await Promise.resolve() await new Promise((r) => setImmediate(r)) log.push('submit fertig') }) form.addEventListener('click', (ev) => log.push(`click ${ev.target.textContent} ${ev.currentTarget.tagName}`)) lauf.addEventListener('click', async () => { await new Promise((r) => setImmediate(r)); log.push('lauf fertig') }) document.body.append(form) await fx.klick(aus) assert.deepEqual(log, [], 'gesperrt: kein Ereignis') await fx.klick(knopf) assert.deepEqual(log, ['click Weiter FORM', 'submit', 'submit fertig']) log.length = 0 await fx.klick(lauf) assert.deepEqual(log, ['click Lauf FORM', 'lauf fertig'], 'kein submit bei type=button') log.length = 0 form.addEventListener('keydown', (ev) => log.push(`${ev.type} ${ev.key}`)) await fx.sende(lauf, 'keydown', { key: 'ArrowRight' }) assert.deepEqual(log, ['keydown ArrowRight']) })) test('Fake-DOM: fetch schreibt { method, url, body } ins Protokoll und liefert die Antworten der Reihe nach', mitDom({ antworten: [{ status: 400, body: { error: 'Nein', fehler: { 'betrieb.telefon': 'fehlt' }, code: 'X' } }, { body: { ok: 1 } }] }, async (fx) => { await assert.rejects(request('/api/termine/einstellungen', { method: 'PATCH', body: { projectId: 'p1', version: 3 } }), (err) => err.status === 400 && err.code === 'X' && err.fehler['betrieb.telefon'] === 'fehlt') assert.deepEqual(await request('/api/termine/x?projectId=p1'), { ok: 1 }) assert.deepEqual(await request('/api/termine/y'), {}, 'ohne Eintrag: 200 mit {}') assert.deepEqual(fx.fetchLog, [ { method: 'PATCH', url: '/api/termine/einstellungen', body: { projectId: 'p1', version: 3 } }, { method: 'GET', url: '/api/termine/x?projectId=p1', body: undefined }, { method: 'GET', url: '/api/termine/y', body: undefined }, ]) })) // ------------------------------------------------------- betrieb(ctx, e, { nur, titel, hinweis }) const BETRIEB_FELDER = ['name', 'email', 'telefon', 'adresse', 'kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl'] const betriebCtx = (fehlt = []) => ({ daten: { fehlt }, nurLesen: false }) test('betrieb mit nur: [email] baut nur das Feld email, lese() liefert nur { email }', mitDom(undefined, async (fx) => { const e = salon() const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] }) assert.deepEqual(felderVon(fx, el), ['betrieb.email']) assert.equal(fx.alle(el, 'input').length, 1) const eingabe = fx.finde(el, '[data-feld="betrieb.email"]') assert.equal(eingabe.value, BETRIEB.email, 'vorbelegt aus der Einstellung') await fx.setze(eingabe, 'neu@example.invalid') const gelesen = lese() assert.deepEqual(Object.keys(gelesen), ['email'], 'Schlüsselmenge') assert.deepEqual(gelesen, { email: 'neu@example.invalid' }) // in den Entwurf geschrieben ändert sich nur email, alles andere bleibt const entwurf = structuredClone(e) lese(entwurf) assert.deepEqual(entwurf.betrieb, { ...e.betrieb, email: 'neu@example.invalid' }) })) test('betrieb mit nur: Teilmenge in der Reihenfolge der Seite, Haken und Adressen gelesen, Unbekanntes wirft', mitDom(undefined, async (fx) => { const e = salon() const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['websiteUrl', 'name', 'kleinunternehmer'] }) assert.deepEqual(felderVon(fx, el), ['betrieb.name', 'betrieb.kleinunternehmer', 'betrieb.websiteUrl'], 'Reihenfolge der Seite, nicht der Aufzählung') await fx.klick(fx.finde(el, '[data-feld="betrieb.kleinunternehmer"]')) await fx.setze(fx.finde(el, '[data-feld="betrieb.websiteUrl"]'), 'https://salon.example.invalid') const gelesen = lese() assert.deepEqual(Object.keys(gelesen).sort(), ['kleinunternehmer', 'name', 'websiteUrl']) assert.equal(gelesen.kleinunternehmer, true) assert.equal(gelesen.websiteUrl, 'https://salon.example.invalid') assert.equal(gelesen.name, BETRIEB.name) assert.throws(() => einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email', 'steuernummer'] }), /steuernummer/) })) test('betrieb ohne nur baut weiter alle neun Felder und schreibt alle neun in den Entwurf', mitDom(undefined, async (fx) => { const e = salon() const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e) assert.deepEqual(felderVon(fx, el).sort(), BETRIEB_FELDER.map((n) => `betrieb.${n}`).sort()) assert.equal(fx.alle(el, 'input').length, 9) assert.equal(fx.text(fx.finde(el, 'h3')), 'Dein Betrieb') const entwurf = { betrieb: {} } lese(entwurf) assert.deepEqual(Object.keys(entwurf.betrieb).sort(), [...BETRIEB_FELDER].sort()) assert.equal(entwurf.betrieb.telefon, BETRIEB.telefon) assert.equal(entwurf.betrieb.kleinunternehmer, false) })) test('betrieb: titel und hinweis ersetzen Überschrift und Erklärsatz', mitDom(undefined, (fx) => { const e = salon() const eigen = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], titel: 'Wohin sollen die Nachrichten?', hinweis: 'Eine Adresse für alles.' }) assert.equal(fx.text(fx.finde(eigen.el, 'h3')), 'Wohin sollen die Nachrichten?') assert.equal(fx.text(fx.finde(eigen.el, '.tm-abschnitt-text')), 'Eine Adresse für alles.') const ohneHinweis = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], hinweis: null }) assert.equal(fx.finde(ohneHinweis.el, '.tm-abschnitt-text'), null) const standard = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] }) assert.equal(fx.text(fx.finde(standard.el, 'h3')), 'Dein Betrieb') assert.match(fx.text(fx.finde(standard.el, '.tm-abschnitt-text')), /Diese Angaben sehen deine Gäste/) })) test('betrieb: der Hinweis auf fehlende Angaben nennt mit nur nur die Felder, die zu sehen sind', mitDom(undefined, (fx) => { const e = salon() const fehlt = ['betrieb.telefon', 'betrieb.adresse'] const alles = einstellungenTab.betrieb(betriebCtx(fehlt), e) assert.match(fx.text(fx.finde(alles.el, '.tm-box-warn')), /deine Telefonnummer und deine Anschrift/, 'ohne nur wie bisher') assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['email'] }).el, '.tm-box-warn'), null) const nurTelefon = fx.text(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['telefon'] }).el, '.tm-box-warn')) assert.match(nurTelefon, /Es fehlt noch: deine Telefonnummer\.$/, 'die Anschrift steht nicht in der Liste dessen, was fehlt') assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx([]), e).el, '.tm-box-warn'), null) })) // ------------------------------------------------------- wochenplanFeld(r, pfad) test('wochenplanFeld: sieben Tagesfelder mit Namen aus dem Pfad, gefüllt aus dem Plan der Person', mitDom(undefined, (fx) => { const r = { id: 'r_anna0001', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] } } const { el } = teamTab.wochenplanFeld(r, 'ressourcen.2') assert.deepEqual(felderVon(fx, el), [1, 2, 3, 4, 5, 6, 7].map((nr) => `ressourcen.2.plan.${nr}`)) assert.deepEqual(fx.alle(el, 'label').map((l) => fx.text(l)), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']) const eingaben = fx.alle(el, 'input') assert.deepEqual(eingaben.map((i) => i.value), ['09:00–12:30, 13:30–18:00', '', '', '', '', '09:00–13:00', '']) assert.ok(eingaben.every((i) => i.getAttribute('placeholder') === 'geschlossen')) assert.equal(fx.text(fx.finde(el, 'legend')), 'Wochenplan') assert.equal(fx.alle(el, '[data-fehler]').length, 7, 'je Tag ein Platz für den Fehler') })) test('wochenplanFeld: gültige Fenster ⇒ { plan } mit genau diesen Fenstern, ein leerer Tag ist geschlossen', mitDom(undefined, async (fx) => { const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.0') await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.1"]'), '09:00–12:30, 13:30–18:00') await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.3"]'), '8:00-12:00') const gelesen = lese() assert.deepEqual(gelesen, { plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 3: [['08:00', '12:00']] } }) assert.equal(gelesen.fehler, undefined) assert.ok(!(2 in gelesen.plan) && !(7 in gelesen.plan), 'leerer Tag ⇒ geschlossen (kein Eintrag)') // ein vorher offener Tag, der geleert wird, ist danach geschlossen const offen = teamTab.wochenplanFeld({ id: 'r1', plan: { 1: [['09:00', '12:00']], 2: [['10:00', '11:00']] } }, 'ressourcen.0') await fx.setze(fx.finde(offen.el, '[data-feld="ressourcen.0.plan.1"]'), '') assert.deepEqual(offen.lese(), { plan: { 2: [['10:00', '11:00']] } }) assert.deepEqual(teamTab.wochenplanFeld(null, 'ressourcen.0').lese(), { plan: {} }, 'ohne Person: alles geschlossen') })) test('wochenplanFeld: ein ungültiges Fenster ⇒ { fehler } mit Text am Feld des Tages, kein Teilplan', mitDom(undefined, async (fx) => { const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.1') await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.1"]'), '09:00–12:00') await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.2"]'), 'abc') await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.5"]'), '09:00–09:00') const gelesen = lese() assert.deepEqual(gelesen, { fehler: { 'ressourcen.1.plan.2': FENSTER_FEHLER, 'ressourcen.1.plan.5': FENSTER_FEHLER } }) assert.equal(gelesen.plan, undefined) assert.ok(typeof gelesen.fehler['ressourcen.1.plan.2'] === 'string' && gelesen.fehler['ressourcen.1.plan.2'].length > 0) })) test('Reiter Team & Plätze: der Editor einer Person speichert den Wochenplan aus wochenplanFeld und zeigt Fehler am Tag', mitDom(undefined, async (fx) => { const einstellung = salon() const anna = einstellung.ressourcen[0] const ctx = { s: {}, projectId: 'p1', daten: { einstellung, version: 4, feeds: {}, fehlt: [] }, nurLesen: false, auswahl: {}, aktuell: () => true, api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) }, meldung() {}, hinweise() {}, zumReiter() {}, neuLaden: async () => {}, } const tafel = h('div', { role: 'tabpanel' }) document.body.append(tafel) teamTab.render(tafel, ctx) await fx.ruhe() await fx.klick(fx.finde(tafel, `li[data-id="${anna.id}"] [data-aktion="bearbeiten"]`)) const form = fx.finde(tafel, (n) => n.tagName === 'FORM' && n.getAttribute('aria-label') === `Bearbeiten: ${anna.name}`) assert.ok(form, 'Editor ist offen') const speichern = fx.finde(form, (n) => n.tagName === 'BUTTON' && n.type === 'submit') const montag = fx.finde(form, '[data-feld="ressourcen.0.plan.1"]') // Fehler: nichts wird gesendet, der Text steht am Tag await fx.setze(montag, 'abc') await fx.klick(speichern) assert.deepEqual(fx.fetchLog, []) assert.equal(fx.text(fx.finde(form, '[data-fehler="ressourcen.0.plan.1"]')), FENSTER_FEHLER) assert.equal(montag.getAttribute('aria-invalid'), 'true') // gültig: PATCH mit dem Plan (alle anderen Tage unverändert) await fx.setze(montag, '10:00-13:00') fx.antworten.push({ body: { einstellung, version: 5, fehlt: [] } }) await fx.klick(speichern) assert.equal(fx.fetchLog.length, 1) const [aufruf] = fx.fetchLog assert.deepEqual([aufruf.method, aufruf.url], ['PATCH', '/api/termine/einstellungen']) assert.equal(aufruf.body.version, 4) assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[1], [['10:00', '13:00']]) assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[2], anna.plan[2]) })) // ------------------------------------------------------- termine-speichern.js: fehlt aus der Antwort const speicherCtx = (einstellung, fehlt) => ({ aktuell: () => true, daten: { einstellung, version: 3, fehlt }, api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) }, hinweise() {}, meldung() {}, neuLaden: async () => {}, }) test('Speichern: antwortet der PATCH mit fehlt, steht es danach in den Daten des Reiters', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4, fehlt: ['betrieb.telefon'] } }] }, async (fx) => { const ctx = speicherCtx(salon(), ['betrieb.telefon', 'betrieb.adresse']) const wurzel = h('div') document.body.append(wurzel) const sp = neuerSpeicher(ctx, wurzel, () => {}) assert.equal(await sp.speichere((entwurf) => { entwurf.betrieb.adresse = 'Hauptstraße 1' }, { container: wurzel }), true) assert.deepEqual(ctx.daten.fehlt, ['betrieb.telefon']) assert.equal(ctx.daten.version, 4) assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['PATCH', '/api/termine/einstellungen']]) })) test('Speichern: ohne fehlt in der Antwort ist es leer', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4 } }] }, async () => { const ctx = speicherCtx(salon(), ['betrieb.telefon']) const wurzel = h('div') document.body.append(wurzel) const sp = neuerSpeicher(ctx, wurzel, () => {}) assert.equal(await sp.speichere(() => true, { container: wurzel }), true) assert.deepEqual(ctx.daten.fehlt, []) })) // ------------------------------------------------------- termine.js: baueCtx test('baueCtx: Schnittstelle, Daten und Meldungen wie der Reiter-Kontext', mitDom({ antworten: [{}, {}, {}, { body: { version: 9, einstellung: null } }, { body: { version: 11 } }] }, async (fx) => { const s = { daten: { version: 3 }, nurLesen: false, meldungEl: h('div', { class: 'tm-meldung', tabindex: '-1' }) } document.body.append(s.meldungEl) let aktuell = true let gezeichnet = 0 const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p 1' }, aktuell: () => aktuell, hinweise: () => { gezeichnet += 1 } }) assert.equal(ctx.s, s) assert.equal(ctx.projectId, 'p 1') assert.equal(ctx.daten, s.daten) assert.equal(ctx.nurLesen, false) assert.equal(typeof ctx.auswahl, 'object') assert.equal(ctx.aktuell(), true) assert.equal(typeof ctx.zumReiter, 'function') // Schnittstelle: GET und DELETE mit projectId in der Adresse, PATCH/POST/PUT mit projectId im Body await ctx.api.get('/buchungen', { von: '2026-10-05' }) await ctx.api.send('DELETE', '/abwesenheiten/a1') await ctx.api.send('PATCH', '/einstellungen', { version: 3 }) assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url, a.body]), [ ['GET', '/api/termine/buchungen?projectId=p+1&von=2026-10-05', undefined], ['DELETE', '/api/termine/abwesenheiten/a1?projectId=p+1', undefined], ['PATCH', '/api/termine/einstellungen', { projectId: 'p 1', version: 3 }], ]) // Meldung nur solange die Ansicht aktuell ist; hinweise() zeichnet nur dann ctx.meldung('Gespeichert.') assert.equal(s.meldungEl.textContent, 'Gespeichert.') ctx.hinweise() assert.equal(gezeichnet, 1) // neu laden: Daten und ctx.daten werden ersetzt und die Hinweise neu gezeichnet const daten = await ctx.neuLaden() assert.deepEqual(daten, { version: 9, einstellung: null }) assert.equal(s.daten, daten) assert.equal(ctx.daten, daten) assert.equal(gezeichnet, 2) assert.equal(fx.fetchLog.at(-1).url, '/api/termine/einstellungen?projectId=p%201') aktuell = false ctx.meldung('Zu spät.') ctx.hinweise() await ctx.neuLaden() assert.equal(s.meldungEl.textContent, 'Gespeichert.', 'eine veraltete Ansicht bekommt keine Meldung') assert.equal(gezeichnet, 2) assert.equal(ctx.daten, daten, 'und keine neuen Daten') // ohne hinweise/zumReiter ist der Kontext trotzdem benutzbar (Fragebogen) const schlicht = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true }) schlicht.hinweise() schlicht.zumReiter('team') }))