1531 lines
89 KiB
JavaScript
1531 lines
89 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import { config } from '../server/config.js'
|
||
import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einstellungen.js'
|
||
import { ausVorlage } from '../server/services/termine/vorlagen.js'
|
||
import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from '../server/services/termine/rechtstexte.js'
|
||
import {
|
||
ENTWURF_BANNER, cssVorschlag, driftWarnung, einbauanleitung, einbauSnippet, esc, gesundheitsMeldungen, kontrast, kuerzeListe, luminanz, textFarbeAuf,
|
||
ticketStand, wochenplanText,
|
||
} from '../server/services/termine/einbauanleitung.js'
|
||
import { TICKET_DETAILS_MAX, anleitungsLink } from '../server/services/featureRegistry.js'
|
||
|
||
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
|
||
// Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js.
|
||
// Der Generator einbauanleitung() (Task D3a, ganz unten) liest config nur beim Aufruf (Portal-Adresse), sonst ist auch er rein.
|
||
|
||
const SITE_KEY = 'abcdefghijklmnop'
|
||
const API = 'https://project.webklar.com'
|
||
|
||
/** Das Snippet aus Abschnitt 1 von docs/funktionen/termine-einbau.md: SITEKEY ersetzt, der Erklaer-Kommentar am Zeilenende entfernt. */
|
||
function snippetAusDoku() {
|
||
const doku = readFileSync(new URL('../docs/funktionen/termine-einbau.md', import.meta.url), 'utf8')
|
||
const abschnitt = doku.split('## 1. Das Snippet')[1]
|
||
assert.ok(abschnitt, 'Abschnitt 1 fehlt in termine-einbau.md')
|
||
const block = /```html\n([\s\S]*?)\n```/.exec(abschnitt)
|
||
assert.ok(block, 'Code-Block in Abschnitt 1 fehlt')
|
||
return block[1]
|
||
.replace('SITEKEY', SITE_KEY)
|
||
.split('\n')
|
||
.map((zeile) => zeile.replace(/\s*<!--.*-->\s*$/, ''))
|
||
.join('\n')
|
||
}
|
||
|
||
const basis = { siteKey: SITE_KEY, apiBase: API, telefon: '06301 1234' }
|
||
|
||
// ---------------------------------------------------------------- esc
|
||
|
||
test('esc maskiert & < > " und macht aus allem anderen Text', () => {
|
||
assert.equal(esc('a & b < c > d "e"'), 'a & b < c > d "e"')
|
||
assert.equal(esc(42), '42')
|
||
assert.equal(esc(undefined), '')
|
||
assert.equal(esc(null), '')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Snippet
|
||
|
||
test('Snippet inline ist zeichengleich zu Abschnitt 1 von termine-einbau.md', () => {
|
||
assert.equal(einbauSnippet(basis), snippetAusDoku())
|
||
assert.equal(einbauSnippet({ ...basis, modus: 'inline' }), snippetAusDoku())
|
||
})
|
||
|
||
test('Snippet: Telefon wird maskiert, ohne Telefon steht der allgemeine Ausweichtext', () => {
|
||
const mit = einbauSnippet({ ...basis, telefon: '06301 <12>' })
|
||
assert.ok(mit.includes('<p>Termine telefonisch unter 06301 <12>.</p>'), mit)
|
||
assert.ok(!mit.includes('<12>'))
|
||
for (const telefon of [undefined, null, '']) {
|
||
assert.ok(einbauSnippet({ siteKey: SITE_KEY, apiBase: API, telefon }).includes('<p>Termine telefonisch vereinbaren.</p>'), String(telefon))
|
||
}
|
||
})
|
||
|
||
test('Snippet: siteKey und Adresse sind maskiert (kein Ausbrechen aus dem Attribut)', () => {
|
||
const s = einbauSnippet({ siteKey: 'a"><script>x', apiBase: 'https://x.de/"&', telefon: undefined })
|
||
assert.ok(s.includes('data-webklar-termine="a"><script>x"'), s)
|
||
assert.ok(s.includes('data-api="https://x.de/"&"'), s)
|
||
assert.ok(s.includes('<script src="https://x.de/"&/embed/termine.js" defer></script>'), s)
|
||
assert.equal((s.match(/<script/g) || []).length, 1, 'nur das eigene Skript-Element')
|
||
})
|
||
|
||
test('Snippet im Modus knopf: data-knopf mit maskiertem Text', () => {
|
||
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: 'Jetzt "buchen" & mehr' })
|
||
assert.ok(s.includes('data-modus="knopf" data-knopf="Jetzt "buchen" & mehr">'), s)
|
||
assert.ok(s.startsWith(`<div data-webklar-termine="${SITE_KEY}" data-api="${API}" data-modus="knopf"`), s)
|
||
assert.ok(s.includes('<p>Termine telefonisch unter 06301 1234.</p>'), 'Ausweichtext bleibt')
|
||
assert.ok(s.endsWith(`<script src="${API}/embed/termine.js" defer></script>`))
|
||
})
|
||
|
||
test('Snippet im Modus knopf ohne Text: kein data-knopf (das Widget nimmt "Termin buchen")', () => {
|
||
for (const knopfText of [undefined, null, '', ' ', '\n\t']) {
|
||
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText })
|
||
assert.ok(s.includes('data-modus="knopf">'), JSON.stringify(knopfText))
|
||
assert.ok(!s.includes('data-knopf'), JSON.stringify(knopfText))
|
||
}
|
||
})
|
||
|
||
test('Snippet: der Knopf-Text ist hoechstens 40 Zeichen lang, Zeilenumbrueche werden zu Leerzeichen', () => {
|
||
const lang = `${'a'.repeat(30)}\n${'b'.repeat(29)}`
|
||
assert.equal(lang.length, 60)
|
||
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: lang })
|
||
const wert = /data-knopf="([^"]*)"/.exec(s)[1]
|
||
assert.equal(wert.length, 40)
|
||
assert.equal(wert, `${'a'.repeat(30)} ${'b'.repeat(9)}`)
|
||
assert.ok(!/[\r\n]/.test(wert))
|
||
for (const umbruch of ['\r\n', '\r', '\u2028']) {
|
||
const t = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `Jetzt${umbruch}buchen` })
|
||
assert.ok(t.includes('data-knopf="Jetzt'), t)
|
||
assert.ok(!/data-knopf="[^"]*[\r\n\u2028]/.test(t), JSON.stringify(umbruch))
|
||
}
|
||
assert.equal(/data-knopf="([^"]*)"/.exec(einbauSnippet({ ...basis, modus: 'knopf', knopfText: 'Jetzt\nbuchen' }))[1], 'Jetzt buchen')
|
||
// gekuerzt wird der Rohtext, nicht der maskierte: 40 Anfuehrungszeichen sind 40 Zeichen, nicht 240
|
||
const ang = einbauSnippet({ ...basis, modus: 'knopf', knopfText: '"'.repeat(60) })
|
||
assert.equal(/data-knopf="([^"]*)"/.exec(ang)[1], '"'.repeat(40))
|
||
})
|
||
|
||
test('Snippet: der Text kuerzt nie mitten in einem Zeichen aus zwei Code-Einheiten', () => {
|
||
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `${'a'.repeat(39)}😀😀` })
|
||
const wert = /data-knopf="([^"]*)"/.exec(s)[1]
|
||
assert.equal(wert, `${'a'.repeat(39)}😀`)
|
||
})
|
||
|
||
test('Snippet: ein Knopf-Text im Modus inline und ein unbekannter Modus ergeben das Inline-Snippet', () => {
|
||
assert.equal(einbauSnippet({ ...basis, modus: 'inline', knopfText: 'Buchen' }), snippetAusDoku())
|
||
for (const modus of ['dialog', 'KNOPF', '', null, 42, {}]) {
|
||
assert.equal(einbauSnippet({ ...basis, modus, knopfText: 'Buchen' }), snippetAusDoku(), JSON.stringify(modus))
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Farben
|
||
|
||
test('luminanz und kontrast: Eckwerte', () => {
|
||
assert.equal(luminanz('#ffffff'), 1)
|
||
assert.equal(luminanz('#000000'), 0)
|
||
assert.equal(kontrast('#000000', '#ffffff'), 21)
|
||
assert.equal(kontrast('#ffffff', '#000000'), 21, 'Reihenfolge egal')
|
||
assert.equal(kontrast('#7c3aed', '#7c3aed'), 1)
|
||
})
|
||
|
||
test('textFarbeAuf: Weiss auf dunklen, dunkles Grau auf hellen, Schwarz nur wo beides unter 4,5 : 1 bleibt', () => {
|
||
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
|
||
assert.equal(textFarbeAuf('#f5c542'), '#111827')
|
||
assert.equal(textFarbeAuf('#000000'), '#ffffff')
|
||
assert.equal(textFarbeAuf('#ffffff'), '#111827')
|
||
assert.equal(textFarbeAuf('#9d4b6f'), '#ffffff')
|
||
// mittlere Toene, bei denen weder Weiss noch das dunkle Grau 4,5 : 1 erreicht
|
||
assert.equal(textFarbeAuf('#808080'), '#000000')
|
||
assert.ok(kontrast('#808080', '#ffffff') < 4.5 && kontrast('#808080', '#111827') < 4.5)
|
||
assert.equal(textFarbeAuf('#6b7280'), '#ffffff')
|
||
})
|
||
|
||
/** Alle 4096 Farben mit Kanalwerten in 17er-Schritten (0, 17, ... 255). */
|
||
function alleFarben() {
|
||
const stufen = Array.from({ length: 16 }, (_, i) => i * 17)
|
||
const hex = (c) => c.toString(16).padStart(2, '0')
|
||
const liste = []
|
||
for (const r of stufen) for (const g of stufen) for (const b of stufen) liste.push(`#${hex(r)}${hex(g)}${hex(b)}`)
|
||
return liste
|
||
}
|
||
|
||
test('textFarbeAuf: jede der 4096 Farben erreicht mit ihrer Schrift mindestens 4,5 : 1', () => {
|
||
const farben = alleFarben()
|
||
assert.equal(farben.length, 4096)
|
||
for (const farbe of farben) {
|
||
const text = textFarbeAuf(farbe)
|
||
assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: ${text}`)
|
||
assert.ok(kontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${kontrast(farbe, text).toFixed(2)} : 1`)
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- CSS-Vorschlag
|
||
|
||
test('cssVorschlag: Kommentar, Selektor und beide Variablen', () => {
|
||
const v = cssVorschlag('#9d4b6f')
|
||
assert.ok(v.css.includes('--wkt-akzent:#9d4b6f;'), v.css)
|
||
assert.ok(v.css.includes('--wkt-akzent-text:#ffffff;'), v.css)
|
||
assert.ok(v.css.includes('[data-webklar-termine]{'), v.css)
|
||
assert.match(v.css, /^\/\*[^*]*\*\/\n/, 'beginnt mit einem Kommentar')
|
||
assert.equal(v.akzent, '#9d4b6f')
|
||
assert.equal(v.akzentText, '#ffffff')
|
||
assert.equal(v.kontrastText, kontrast('#9d4b6f', '#ffffff'))
|
||
assert.equal(v.kontrastSeite, kontrast('#9d4b6f', '#ffffff'))
|
||
assert.deepEqual(v.hinweise, [], 'ein dunkler Akzent braucht keine Warnung')
|
||
// heller Akzent: dunkle Schrift
|
||
const hell = cssVorschlag('#f5c542')
|
||
assert.equal(hell.akzentText, '#111827')
|
||
assert.ok(hell.css.includes('--wkt-akzent-text:#111827;'))
|
||
assert.equal(hell.kontrastText, kontrast('#f5c542', '#111827'))
|
||
assert.ok(hell.kontrastText >= 4.5)
|
||
})
|
||
|
||
test('cssVorschlag: nur gueltige #rrggbb, sonst die Vorgabefarbe und der Rohtext steht nirgends', () => {
|
||
assert.equal(VORGABE_FARBE, '#7c3aed')
|
||
for (const roh of ['red', '#fff', undefined, null, 42, '#9d4b6f;}</style>', '#12345g', ' #9d4b6f', '#9d4b6f\n']) {
|
||
const v = cssVorschlag(roh)
|
||
assert.equal(v.akzent, '#7c3aed', String(roh))
|
||
assert.ok(v.css.includes('--wkt-akzent:#7c3aed;'), String(roh))
|
||
assert.ok(v.css.includes(`--wkt-akzent-text:${textFarbeAuf('#7c3aed')};`), String(roh))
|
||
const ausgabe = JSON.stringify(v)
|
||
for (const stueck of ['red', '</style>', '12345g', '#9d4b6f', '#fff;']) {
|
||
if (typeof roh === 'string' && roh.includes(stueck)) assert.ok(!ausgabe.includes(stueck), `${JSON.stringify(roh)}: ${stueck} steht in der Ausgabe`)
|
||
}
|
||
assert.ok(!/[<>]/.test(v.css), 'kein HTML im CSS')
|
||
}
|
||
// Grossbuchstaben sind gueltig und werden klein geschrieben
|
||
assert.equal(cssVorschlag('#9D4B6F').akzent, '#9d4b6f')
|
||
})
|
||
|
||
test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent unter 3 : 1 liegt', () => {
|
||
const v = cssVorschlag('#f5c542')
|
||
assert.ok(v.kontrastSeite < 3, String(v.kontrastSeite))
|
||
assert.equal(v.hinweise.length, 1)
|
||
assert.match(v.hinweise[0], /Weiß/)
|
||
assert.match(v.hinweise[0], /1,6 : 1/, 'Kontrast mit deutschem Komma')
|
||
assert.match(v.hinweise[0], /3 : 1/)
|
||
assert.match(v.hinweise[0], /Rahmen/)
|
||
// knapp ueber der Grenze: keine Warnung
|
||
const gut = cssVorschlag('#0f766e')
|
||
assert.ok(gut.kontrastSeite >= 3)
|
||
assert.deepEqual(gut.hinweise, [])
|
||
// Grenzfall: genau an 3 : 1 warnt nicht, knapp darunter schon
|
||
for (const farbe of alleFarben()) {
|
||
const k = kontrast(farbe, '#ffffff')
|
||
assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`)
|
||
}
|
||
})
|
||
|
||
// ================================================================ Generator einbauanleitung() 1 (Task D3a)
|
||
// Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Rein: kein I/O, nur ausdruecklich benannte Felder.
|
||
|
||
const NOW = new Date('2026-10-05T10:00:00Z') // Montag, 12:00 Berliner Zeit
|
||
const SPAETER = new Date('2026-10-06T10:00:00Z') // einen Tag spaeter: nur die "Erzeugt am"-Zeile aendert sich
|
||
const GEHEIM = 'XYZ-GEHEIM'
|
||
// Endregel (Leistungsbeschreibung Nr. 4 Abs. 9): wer den Datenschutz-Absatz einfügt; Checkliste und Schritt 4 der Kurzfassung nennen sie
|
||
const DATENSCHUTZ_ENDREGEL =
|
||
'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; '
|
||
+ 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt'
|
||
|
||
const BETRIEB = {
|
||
name: 'Salon Muster',
|
||
email: 'inhaber-intern@salon-muster.example',
|
||
telefon: '0631 123456',
|
||
adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
|
||
kleinunternehmer: false,
|
||
impressumUrl: 'https://salon-muster.example/impressum',
|
||
datenschutzUrl: 'https://salon-muster.example/datenschutz',
|
||
bundesland: 'RP',
|
||
}
|
||
|
||
/** Salon-Vorlage mit Farbe, zwei Personen und einem Platz, Wochenplan, Abwesenheiten, Sonderoeffnungen und einer Anfrage-Leistung. */
|
||
function fixtureEinstellung() {
|
||
const e = ausVorlage('salon', BETRIEB, NOW)
|
||
e.farbe = '#9d4b6f'
|
||
const [anna] = e.ressourcen
|
||
anna.name = 'Anna'
|
||
anna.plan = { 1: [['09:00', '12:30'], ['14:00', '18:00']], 3: [['09:00', '12:30']], 7: [['10:00', '12:00']] }
|
||
e.ressourcen.push(
|
||
{ id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() },
|
||
{ id: 'r_stuhl001', typ: 'platz', name: 'Stuhl 2', aktiv: false, plan: {}, feedToken: neuerFeedToken() },
|
||
)
|
||
e.leistungen[0].ressourcen = [anna.id, 'r_ben00001']
|
||
e.leistungen[1].aktiv = false // Damenschnitt
|
||
e.leistungen[2].buchungsart = 'anfrage'
|
||
e.leistungen[2].preisArt = 'keiner'
|
||
e.leistungen[2].preisCent = 0
|
||
e.abwesenheiten = [
|
||
{ id: 'a_alt00001', ressourceId: anna.id, von: '2026-09-28', bis: '2026-10-02', titel: 'Urlaub vorbei' },
|
||
{ id: 'a_jetzt001', ressourceId: 'r_ben00001', von: '2026-10-05', bis: '2026-10-05', titel: 'Fortbildung' },
|
||
{ id: 'a_laeuft01', ressourceId: anna.id, von: '2026-10-01', bis: '2026-10-07', titel: 'Kur' },
|
||
{ id: 'a_zukunft1', ressourceId: null, von: '2026-12-24', bis: '2026-12-31', titel: 'Betriebsferien' },
|
||
]
|
||
e.sonderoeffnungen = { '2026-10-03': [['09:00', '12:00']], '2026-12-24': [], '2026-11-07': [['10:00', '14:00']] }
|
||
e.gastFelder = {
|
||
telefon: 'pflicht',
|
||
nachricht: 'optional',
|
||
zusatz: [
|
||
{ key: 'haare', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
|
||
{ key: 'wunsch', label: 'Wunschfrisur', typ: 'text', optionen: [], pflicht: false },
|
||
],
|
||
}
|
||
return e
|
||
}
|
||
|
||
const PROJEKT = { projectName: 'Salon Muster Website', subdomain: 'salon-muster', liveDomain: 'salon-muster.example', previewUrl: 'https://salon-muster.project.webklar.com' }
|
||
|
||
/** Standard-Aufruf des Generators; Abweichungen ueber `zusatz`. */
|
||
function erzeuge(zusatz = {}) {
|
||
return einbauanleitung({
|
||
feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup' },
|
||
project: PROJEKT,
|
||
einstellung: fixtureEinstellung(),
|
||
fragebogen: null,
|
||
version: 3,
|
||
updatedAt: '2026-10-03T08:15:00.000Z',
|
||
now: NOW,
|
||
kunde: { name: 'Salon Muster GbR' },
|
||
...zusatz,
|
||
})
|
||
}
|
||
|
||
const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id)
|
||
const zeilen = (text) => text.split('\n')
|
||
|
||
test('D3a: Ergebnisform, die ersten sechs Abschnitte in fester Reihenfolge, keiner davon kopierbar', () => {
|
||
const r = erzeuge({ entwurf: false })
|
||
assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen'])
|
||
assert.deepEqual(r.abschnitte.slice(0, 6).map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen'])
|
||
for (const a of r.abschnitte) {
|
||
assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
|
||
assert.ok(a.titel && typeof a.titel === 'string', a.id)
|
||
assert.ok(a.text && typeof a.text === 'string', a.id)
|
||
}
|
||
for (const a of r.abschnitte.slice(0, 6)) assert.equal(a.kopierbar, false, a.id)
|
||
assert.deepEqual(r.warnungen, [], 'ein vollstaendiger Betrieb ohne Entwurf-Kennzeichnung hat keine Warnungen')
|
||
assert.equal(typeof r.kurz, 'string', 'die Kurzfassung steht seit D4 im Ergebnis (Inhalt: Tests ab "D4")')
|
||
assert.ok(r.kurz.length > 0)
|
||
// der Volltext fuehrt jeden Abschnitt mit Ueberschrift, die Datenschutz-Fassung der anderen Anrede fehlt (D3b)
|
||
const ohneDu = r.abschnitte.filter((a) => a.id !== 'datenschutz_du')
|
||
assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), ohneDu.map((a) => `## ${a.titel}`))
|
||
for (const a of ohneDu) assert.ok(r.text.includes(a.text), a.id)
|
||
})
|
||
|
||
test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => {
|
||
assert.deepEqual(erzeuge().stand, { version: 3, updatedAt: '2026-10-03T08:15:00.000Z' })
|
||
assert.deepEqual(erzeuge({ updatedAt: undefined }).stand, { version: 3, updatedAt: null })
|
||
assert.deepEqual(erzeuge({ version: 0, updatedAt: undefined }).stand, { version: 0, updatedAt: null })
|
||
})
|
||
|
||
test('D3a Modell: Betrieb, Kontakt, Adressen, Farbe, Anrede, Portal-Adresse, Erzeugt-am und Stand der Einstellungen', () => {
|
||
const modell = abschnitt(erzeuge(), 'modell').text
|
||
for (const erwartet of [
|
||
'Erzeugt am 05.10.2026',
|
||
'Stand der Einstellungen: Version 3, zuletzt geändert 03.10.2026',
|
||
'Kunde: Salon Muster GbR',
|
||
'Projekt: Salon Muster Website (salon-muster.example)',
|
||
'Betrieb: Salon Muster',
|
||
'Anschrift: Hauptstraße 1, 67655 Kaiserslautern',
|
||
'Telefon: 0631 123456',
|
||
'Impressum: https://salon-muster.example/impressum',
|
||
'Datenschutzerklärung: https://salon-muster.example/datenschutz',
|
||
'Farbe: #9d4b6f',
|
||
'Anrede der Gäste: Sie',
|
||
'Gäste: Privatpersonen (Verbraucher)',
|
||
`Portal-Adresse (data-api): ${config.publicBaseUrl}`,
|
||
'Umfang: 3 Personen und Plätze (2 aktiv), 4 Leistungen (3 aktiv)',
|
||
]) assert.ok(zeilen(modell).includes(erwartet), `${erwartet}\n---\n${modell}`)
|
||
})
|
||
|
||
test('D3a Modell: fehlende Angaben stehen als nicht angegeben, Du-Anrede, Projekt ohne Domain, Version 0, keine Kundenzeile', () => {
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.telefon = ''
|
||
e.betrieb.adresse = ''
|
||
e.betrieb.impressumUrl = null
|
||
e.betrieb.datenschutzUrl = null
|
||
e.anrede = 'du'
|
||
e.recht = { gaesteSind: 'gemischt', freizeit: true }
|
||
const modell = abschnitt(erzeuge({ einstellung: e, project: { projectName: 'Nur Name' }, kunde: null, version: 0, updatedAt: null }), 'modell').text
|
||
const z = zeilen(modell)
|
||
for (const erwartet of [
|
||
'Betrieb: Salon Muster',
|
||
'Anschrift: nicht angegeben',
|
||
'Telefon: nicht angegeben',
|
||
'Impressum: nicht angegeben',
|
||
'Datenschutzerklärung: nicht angegeben',
|
||
'Anrede der Gäste: Du',
|
||
'Gäste: Privatpersonen und Unternehmen, Freizeitleistung',
|
||
'Projekt: Nur Name',
|
||
'Stand der Einstellungen: keine gespeicherte Version',
|
||
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${modell}`)
|
||
assert.ok(!z.some((x) => x.startsWith('Kunde:')), 'ohne Kunde keine Kundenzeile')
|
||
})
|
||
|
||
test('D3a Modell: AGB- und Website-Adresse nur, wenn vorhanden; die Portal-Adresse wird beim Aufruf gelesen', () => {
|
||
const ohne = abschnitt(erzeuge(), 'modell').text
|
||
assert.ok(!/AGB|Website:/.test(ohne), ohne)
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.agbUrl = 'https://salon-muster.example/agb'
|
||
e.betrieb.websiteUrl = 'https://salon-muster.example/'
|
||
const mit = zeilen(abschnitt(erzeuge({ einstellung: e }), 'modell').text)
|
||
assert.ok(mit.includes('AGB: https://salon-muster.example/agb'))
|
||
assert.ok(mit.includes('Website: https://salon-muster.example/'))
|
||
const vorher = config.publicBaseUrl
|
||
try {
|
||
config.publicBaseUrl = 'https://portal.test.example'
|
||
assert.ok(zeilen(abschnitt(erzeuge(), 'modell').text).includes('Portal-Adresse (data-api): https://portal.test.example'))
|
||
} finally {
|
||
config.publicBaseUrl = vorher
|
||
}
|
||
})
|
||
|
||
test('D3a Leistungen: Name, Dauer, Preis, Buchungsart, Personen; inaktive und Anfrage-Leistungen markiert', () => {
|
||
const text = abschnitt(erzeuge(), 'leistungen').text
|
||
const z = zeilen(text)
|
||
assert.equal(z[0], '4 Leistungen (3 aktiv):')
|
||
assert.ok(z.includes('- Herrenschnitt: 30 Min. (+ 5 Min. Pause danach), 28,00 €, sofort buchbar, Personen: Anna, Ben'), text)
|
||
assert.ok(z.includes('- Damenschnitt (inaktiv): 45 Min. (+ 5 Min. Pause danach), ab 45,00 €, sofort buchbar, Personen: Anna'), text)
|
||
assert.ok(z.includes('- Kinderschnitt: 30 Min. (+ 5 Min. Pause danach), kein Preis, nur als Anfrage, Personen: Anna'), text)
|
||
assert.ok(z.includes('- Färben: 120 Min. (+ 10 Min. Pause danach), ab 75,00 €, sofort buchbar, Personen: Anna'), text)
|
||
assert.equal(z.filter((x) => x.includes('(inaktiv)')).length, 1, 'nur die inaktive Leistung ist markiert')
|
||
})
|
||
|
||
test('D3a Leistungen: Plaetze heissen Plaetze, gemischt Personen und Plaetze, ohne Pause keine Klammer, unbekannte Zuordnung faellt weg', () => {
|
||
const e = fixtureEinstellung()
|
||
e.leistungen[0].ressourcen = ['r_stuhl001']
|
||
e.leistungen[1].ressourcen = [e.ressourcen[0].id, 'r_stuhl001', 'r_gibtsnicht']
|
||
e.leistungen[3].pufferNach = 0
|
||
e.leistungen[3].ressourcen = []
|
||
const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'leistungen').text)
|
||
assert.ok(z.some((x) => x.startsWith('- Herrenschnitt:') && x.endsWith('Plätze: Stuhl 2')), z.join('\n'))
|
||
assert.ok(z.some((x) => x.startsWith('- Damenschnitt (inaktiv):') && x.endsWith('Personen und Plätze: Anna, Stuhl 2')), z.join('\n'))
|
||
assert.ok(z.includes('- Färben: 120 Min., ab 75,00 €, sofort buchbar, keine Person und kein Platz zugeordnet'), z.join('\n'))
|
||
})
|
||
|
||
test('D3a Ressourcen: Wochenplan je Tag, nur kuenftige Abwesenheiten, Sonderoeffnungen ab heute', () => {
|
||
const text = abschnitt(erzeuge(), 'ressourcen').text
|
||
const z = zeilen(text)
|
||
assert.ok(z.includes('- Anna (Person)'), text)
|
||
assert.ok(z.includes(' Mo 09:00–12:30, 14:00–18:00'), text)
|
||
assert.ok(z.includes(' Mi 09:00–12:30'), text)
|
||
assert.ok(z.includes(' So 10:00–12:00'), text)
|
||
assert.ok(z.includes('- Ben (Person)') && z.includes(' Di 08:00–16:00'), text)
|
||
assert.ok(z.includes('- Stuhl 2 (Platz, inaktiv)'), text)
|
||
assert.ok(z.includes(' keine Zeiten eingetragen'), text)
|
||
// Abwesenheiten: abgelaufene fehlen, heute endende und laufende bleiben, nach Beginn sortiert
|
||
const ab = z.slice(z.indexOf('Abwesenheiten (ab heute):') + 1).filter((x) => x.startsWith('- ') && /Fortbildung|Kur|Betriebsferien|Urlaub/.test(x))
|
||
assert.deepEqual(ab, [
|
||
'- 01.10.2026–07.10.2026: Kur (Anna)',
|
||
'- 05.10.2026: Fortbildung (Ben)',
|
||
'- 24.12.2026–31.12.2026: Betriebsferien (alle)',
|
||
])
|
||
assert.ok(!text.includes('Urlaub vorbei'), 'die abgelaufene Abwesenheit steht nirgends')
|
||
// Sonderoeffnungen: der 03.10. liegt vor heute und faellt weg, der 07.11. kommt vor dem 24.12.
|
||
const so = z.slice(z.indexOf('Sonderöffnungszeiten (ab heute, für alle):') + 1).filter((x) => x.startsWith('- '))
|
||
assert.deepEqual(so.slice(-2), ['- 07.11.2026: 10:00–14:00', '- 24.12.2026: geschlossen'])
|
||
assert.ok(!text.includes('03.10.2026'))
|
||
})
|
||
|
||
test('D3a Ressourcen: ohne Abwesenheiten und Sonderoeffnungen entfallen die Ueberschriften; ohne Ressourcen steht ein Hinweis', () => {
|
||
const e = fixtureEinstellung()
|
||
e.abwesenheiten = [{ id: 'a_alt00001', ressourceId: null, von: '2026-01-01', bis: '2026-01-02', titel: 'alt' }]
|
||
e.sonderoeffnungen = { '2026-01-01': [] }
|
||
const text = abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text
|
||
assert.ok(!text.includes('Abwesenheiten'), text)
|
||
assert.ok(!text.includes('Sonderöffnungszeiten'), text)
|
||
e.ressourcen = []
|
||
assert.equal(abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text, 'Keine Personen oder Plätze angelegt.')
|
||
})
|
||
|
||
test('D3a Regeln: Raster, Vorlauf, Horizont, Absagefrist, Obergrenze, Storno-Text mit Fassung, Zahlung', () => {
|
||
const e = fixtureEinstellung()
|
||
const text = abschnitt(erzeuge({ einstellung: e }), 'regeln').text
|
||
const z = zeilen(text)
|
||
for (const erwartet of [
|
||
'Raster: 15 Minuten',
|
||
'Vorlauf: 2 Stunden',
|
||
'Buchungszeitraum: 60 Tage im Voraus',
|
||
'Absage durch Gäste: bis 24 Stunden vor dem Termin',
|
||
'Offene Termine je E-Mail-Adresse: höchstens 3',
|
||
'Person wählbar: ja',
|
||
'Feiertage: geschlossen (Bundesland RP)',
|
||
'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte',
|
||
'Preishinweis: inkl. USt',
|
||
'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt',
|
||
`Stornobedingungen (Fassung ${e.storno.version}):`,
|
||
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
|
||
assert.match(e.storno.version, /^\d{4}-\d{2}-\d{2}-\d+$/)
|
||
assert.ok(text.includes(` ${e.storno.text}`), 'der Storno-Text steht vollstaendig, eingerueckt')
|
||
})
|
||
|
||
test('D3a Regeln: andere Werte, kein Storno-Text, Unternehmen ohne Widerruf, Kleinunternehmer, keine Zahlarten', () => {
|
||
const e = fixtureEinstellung()
|
||
e.regeln = { raster: 30, vorlaufMin: 0, horizontTage: 14, absageBisStd: 0, maxOffeneJeEmail: 1, personWaehlbar: false }
|
||
e.feiertageGeschlossen = false
|
||
e.storno = { text: '', version: '2026-10-01-2' }
|
||
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
|
||
e.betrieb.kleinunternehmer = true
|
||
e.zahlung = { modus: 'vorort', vorOrtArten: [] }
|
||
const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text)
|
||
for (const erwartet of [
|
||
'Raster: 30 Minuten',
|
||
'Vorlauf: kein Vorlauf',
|
||
'Buchungszeitraum: 14 Tage im Voraus',
|
||
'Absage durch Gäste: bis zum Terminbeginn',
|
||
'Offene Termine je E-Mail-Adresse: höchstens 1',
|
||
'Person wählbar: nein',
|
||
'Feiertage: geöffnet wie im Wochenplan (Bundesland RP)',
|
||
'Bezahlung vor Ort',
|
||
'Preishinweis: kein Ausweis der USt nach § 19 UStG',
|
||
'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
|
||
'Stornobedingungen: keine hinterlegt',
|
||
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${z.join('\n')}`)
|
||
assert.ok(!z.join('\n').includes('2026-10-01-2'), 'ohne Text keine Fassung im Text')
|
||
})
|
||
|
||
test('D3a Regeln: Vorlauf in Minuten, Stunden und Tagen, Absagefrist in Tagen', () => {
|
||
const vorlauf = (minuten) => {
|
||
const e = fixtureEinstellung()
|
||
e.regeln.vorlaufMin = minuten
|
||
return zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text).find((x) => x.startsWith('Vorlauf:'))
|
||
}
|
||
assert.equal(vorlauf(45), 'Vorlauf: 45 Minuten')
|
||
assert.equal(vorlauf(60), 'Vorlauf: 1 Stunde')
|
||
assert.equal(vorlauf(90), 'Vorlauf: 90 Minuten')
|
||
assert.equal(vorlauf(1440), 'Vorlauf: 1 Tag')
|
||
assert.equal(vorlauf(20160), 'Vorlauf: 14 Tage')
|
||
})
|
||
|
||
test('D3a Gastfelder: Name und E-Mail immer Pflicht, Telefon und Nachricht nach Einstellung, Zusatzfelder mit Auswahl', () => {
|
||
const text = abschnitt(erzeuge(), 'gastfelder').text
|
||
const z = zeilen(text)
|
||
for (const erwartet of [
|
||
'Name: Pflicht',
|
||
'E-Mail-Adresse: Pflicht',
|
||
'Telefon: Pflicht',
|
||
'Nachricht: optional',
|
||
'Zusatzfelder (2):',
|
||
'- Haarlänge (Auswahl: kurz, mittel, lang; Pflicht)',
|
||
'- Wunschfrisur (Text; optional)',
|
||
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
|
||
const e = fixtureEinstellung()
|
||
e.gastFelder = { telefon: 'aus', nachricht: 'aus', zusatz: [] }
|
||
const leer = zeilen(abschnitt(erzeuge({ einstellung: e }), 'gastfelder').text)
|
||
assert.ok(leer.includes('Telefon: aus') && leer.includes('Nachricht: aus') && leer.includes('Zusatzfelder: keine'), leer.join('\n'))
|
||
})
|
||
|
||
test('D3a Benachrichtigungen: Mails an Gaeste nach Konfiguration, Mails an den Betrieb ohne Adresse, Kalender-Abo ohne Link', () => {
|
||
const text = abschnitt(erzeuge(), 'benachrichtigungen').text
|
||
const z = zeilen(text)
|
||
for (const erwartet of [
|
||
'Gäste werden mit „Sie“ angesprochen. Absender ist der Betriebsname (Salon Muster), Antworten gehen an den Betrieb.',
|
||
'Mails an Gäste:',
|
||
'- Bestätigungslink zur E-Mail-Adresse (gültig 30 Minuten)',
|
||
'- Terminbestätigung (sofort buchbare Leistungen)',
|
||
'- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)',
|
||
'- Erinnerung 24 Stunden vor dem Termin (nur wenn der Gast sie ankreuzt)',
|
||
'- Absage und Verschiebung',
|
||
'- Eingangsbestätigung des Widerrufs',
|
||
'Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):',
|
||
'- Neue Buchung',
|
||
'- Neue Terminanfrage',
|
||
'- Absage oder Verschiebung durch den Gast',
|
||
'- Widerruf',
|
||
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
|
||
assert.ok(text.includes('Kalender-Abo (ICS)') && text.includes('im Portal'), text)
|
||
assert.ok(!text.includes(BETRIEB.email), 'die Betriebs-E-Mail steht nicht in der Anleitung')
|
||
// die Gueltigkeit des Bestaetigungslinks wird beim Aufruf aus der Konfiguration gelesen
|
||
const vorher = config.termine.holdMinutes
|
||
try {
|
||
config.termine.holdMinutes = 45
|
||
assert.ok(zeilen(abschnitt(erzeuge(), 'benachrichtigungen').text).includes('- Bestätigungslink zur E-Mail-Adresse (gültig 45 Minuten)'))
|
||
} finally {
|
||
config.termine.holdMinutes = vorher
|
||
}
|
||
})
|
||
|
||
test('D3a Benachrichtigungen: ohne Erinnerung, ohne Anfrage-Leistung, ohne Widerruf und mit Du-Anrede entfallen die Zeilen', () => {
|
||
const e = fixtureEinstellung()
|
||
e.anrede = 'du'
|
||
e.erinnerung = { anbieten: false, stundenVorher: 24 }
|
||
e.leistungen[2].buchungsart = 'sofort'
|
||
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
|
||
const text = abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text
|
||
const z = zeilen(text)
|
||
assert.ok(text.startsWith('Gäste werden mit „Du“ angesprochen.'), text)
|
||
for (const fehlt of ['- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', '- Neue Terminanfrage', '- Eingangsbestätigung des Widerrufs', '- Widerruf']) {
|
||
assert.ok(!z.includes(fehlt), `${fehlt}\n---\n${text}`)
|
||
}
|
||
assert.ok(!z.some((x) => x.startsWith('- Erinnerung')), text)
|
||
assert.ok(z.includes('- Neue Buchung') && z.includes('- Absage oder Verschiebung durch den Gast'), text)
|
||
// nur Anfrage-Leistungen: keine Terminbestaetigung und keine "Neue Buchung"
|
||
e.leistungen.forEach((l) => { l.buchungsart = 'anfrage' })
|
||
const nurAnfrage = zeilen(abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text)
|
||
assert.ok(!nurAnfrage.includes('- Terminbestätigung (sofort buchbare Leistungen)') && !nurAnfrage.includes('- Neue Buchung'))
|
||
assert.ok(nurAnfrage.includes('- Neue Terminanfrage'))
|
||
// inaktive Leistungen zaehlen nicht
|
||
const f = fixtureEinstellung()
|
||
f.leistungen[2].aktiv = false
|
||
assert.ok(!zeilen(abschnitt(erzeuge({ einstellung: f }), 'benachrichtigungen').text).includes('- Neue Terminanfrage'))
|
||
})
|
||
|
||
test('D3a feedToken: jede Ressource hat einen echten Token, das Ergebnis enthaelt weder einen Token noch das Wort feedToken', () => {
|
||
const e = fixtureEinstellung()
|
||
assert.ok(e.ressourcen.length >= 3)
|
||
for (const r of e.ressourcen) assert.match(r.feedToken, /^[A-Za-z0-9_-]{43}$/)
|
||
const json = JSON.stringify(erzeuge({ einstellung: e }))
|
||
assert.ok(!json.includes('feedToken'))
|
||
assert.ok(!json.toLowerCase().includes('feedtoken'))
|
||
for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken), `Token von ${r.name} steht im Ergebnis`)
|
||
})
|
||
|
||
test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und Kopf tauchen nirgends auf', () => {
|
||
const e = fixtureEinstellung()
|
||
e.geheim = GEHEIM
|
||
e.betrieb.geheim = GEHEIM
|
||
e.regeln.geheim = GEHEIM
|
||
e.gastFelder.geheim = GEHEIM
|
||
e.gastFelder.zusatz[0].geheim = GEHEIM
|
||
e.storno.geheim = GEHEIM
|
||
e.ressourcen[0].geheim = GEHEIM
|
||
e.ressourcen[0].notiz = 'NOTIZ-INTERN'
|
||
e.leistungen[0].geheim = GEHEIM
|
||
e.leistungen[0].notiz = 'NOTIZ-INTERN'
|
||
e.leistungen[0].beschreibung = 'BESCHREIBUNG-NUR-FUER-GAESTE'
|
||
e.abwesenheiten[3].geheim = GEHEIM
|
||
const json = JSON.stringify(erzeuge({
|
||
einstellung: e,
|
||
feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, geheim: GEHEIM, feedToken: GEHEIM, adminNote: GEHEIM },
|
||
project: { ...PROJEKT, geheim: GEHEIM, apiKey: GEHEIM },
|
||
kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM },
|
||
fragebogen: { geheim: GEHEIM },
|
||
}))
|
||
for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE']) assert.ok(!json.includes(verboten), verboten)
|
||
// die Betriebs-E-Mail steht nur in den beiden Datenschutz-Absaetzen (Verantwortlicher), sonst nirgends
|
||
const r = erzeuge({ einstellung: e })
|
||
for (const a of r.abschnitte) assert.equal(a.text.includes(BETRIEB.email), a.id.startsWith('datenschutz_'), a.id)
|
||
})
|
||
|
||
test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => {
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.name = 'Evil\n## Regeln\nRaster: 1'
|
||
e.ressourcen[0].name = '<img src=x onerror=alert(1)> & "Anna"'
|
||
e.leistungen[0].name = 'Schnitt\r\n## Gastfelder'
|
||
e.abwesenheiten[3].titel = 'Ferien\n## Modell'
|
||
const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } })
|
||
assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12, r.text) // 13 Abschnitte, nur eine Datenschutz-Fassung
|
||
assert.ok(r.text.includes('<img src=x onerror=alert(1)> & "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein')
|
||
assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text)
|
||
assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1)
|
||
})
|
||
|
||
test('D3a ist deterministisch und veraendert die Eingabe nicht', () => {
|
||
const e = fixtureEinstellung()
|
||
// Abwesenheiten absichtlich unsortiert: der Generator sortiert, darf dabei die Eingabe aber nicht umordnen
|
||
e.abwesenheiten.reverse()
|
||
const vorher = structuredClone(e)
|
||
const a = erzeuge({ einstellung: e })
|
||
assert.deepEqual(e, vorher, 'nach dem ersten Aufruf: die Einstellung bleibt unveraendert (kein Sortieren oder Filtern in place)')
|
||
const b = erzeuge({ einstellung: e })
|
||
assert.deepEqual(e, vorher, 'nach dem zweiten Aufruf ebenso')
|
||
assert.deepEqual(a, b)
|
||
})
|
||
|
||
test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung vergangener Dinge', () => {
|
||
const normal = (r) => ({ ...r, text: r.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT'), abschnitte: r.abschnitte.map((a) => ({ ...a, text: a.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT') })) })
|
||
// einen Tag spaeter, ohne dass etwas ablaeuft (die Fortbildung liegt hier erst am 20.10.): nur die Erzeugt-am-Zeile unterscheidet
|
||
const spaeterEnde = fixtureEinstellung()
|
||
spaeterEnde.abwesenheiten[1].von = '2026-10-20'
|
||
spaeterEnde.abwesenheiten[1].bis = '2026-10-20'
|
||
const eins = erzeuge({ einstellung: spaeterEnde })
|
||
const zwei = erzeuge({ now: SPAETER, einstellung: spaeterEnde })
|
||
assert.notDeepEqual(eins, zwei)
|
||
assert.ok(abschnitt(eins, 'modell').text.includes('Erzeugt am 05.10.2026'))
|
||
assert.ok(abschnitt(zwei, 'modell').text.includes('Erzeugt am 06.10.2026'))
|
||
assert.deepEqual(normal(eins), normal(zwei), 'sonst bleibt alles gleich')
|
||
// mit der Fixture-Abwesenheit vom 05.10. faellt sie am Folgetag weg (bis < heute)
|
||
const a = erzeuge()
|
||
const b = erzeuge({ now: SPAETER })
|
||
assert.ok(abschnitt(a, 'ressourcen').text.includes('Fortbildung'))
|
||
assert.ok(!abschnitt(b, 'ressourcen').text.includes('Fortbildung'))
|
||
assert.ok(abschnitt(b, 'ressourcen').text.includes('Kur'), 'die laufende Kur bleibt')
|
||
// zwei Monate spaeter: vergangene Abwesenheiten und Sonderoeffnungen fehlen, sonst bleibt alles gleich
|
||
const spaet = erzeuge({ now: new Date('2026-12-20T10:00:00Z') })
|
||
const ressourcen = abschnitt(spaet, 'ressourcen').text
|
||
assert.ok(!ressourcen.includes('Kur') && !ressourcen.includes('Fortbildung') && !ressourcen.includes('07.11.2026'), ressourcen)
|
||
assert.ok(ressourcen.includes('Betriebsferien') && ressourcen.includes('24.12.2026: geschlossen'), ressourcen)
|
||
for (const id of ['leistungen', 'regeln', 'gastfelder', 'benachrichtigungen']) assert.equal(abschnitt(spaet, id).text, abschnitt(a, id).text, id)
|
||
// ein Abwesenheitstag, der in Berlin schon der naechste Tag ist, zaehlt nach Berliner Kalender (23:30 UTC = 01:30 Berlin am 06.10.)
|
||
const nachtgrenze = erzeuge({ now: new Date('2026-10-05T23:30:00Z') })
|
||
assert.ok(!abschnitt(nachtgrenze, 'ressourcen').text.includes('Fortbildung'), 'der 05.10. ist in Berlin schon vorbei')
|
||
assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026'))
|
||
})
|
||
|
||
test('D3a ohne Einstellung wirft nichts; ein ungueltiges now wirft', () => {
|
||
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null })
|
||
assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'], 'D3b: nur der Einbau-Code')
|
||
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
|
||
for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) {
|
||
assert.throws(() => erzeuge({ now }), TypeError, String(now))
|
||
}
|
||
})
|
||
|
||
test('D3a Hilfen: wochenplanText und kuerzeListe', () => {
|
||
assert.deepEqual(wochenplanText({ 1: [['09:00', '12:30'], ['14:00', '18:00']], 5: [['09:00', '13:00']] }), ['Mo 09:00–12:30, 14:00–18:00', 'Fr 09:00–13:00'])
|
||
assert.deepEqual(wochenplanText({ 7: [['10:00', '12:00']], 2: [] }), ['So 10:00–12:00'], 'leere Tage fehlen, Reihenfolge Montag bis Sonntag')
|
||
assert.deepEqual(wochenplanText({}), [])
|
||
assert.deepEqual(wochenplanText(undefined), [])
|
||
assert.deepEqual(wochenplanText({ 1: 'kaputt', 8: [['09:00', '10:00']], 3: [['09:00']] }), [], 'Unbrauchbares wird uebersprungen')
|
||
assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 3), ['a', 'b', 'c'])
|
||
assert.deepEqual(kuerzeListe(['a', 'b', 'c', 'd', 'e'], 2), ['a', 'b', '… und 3 weitere'])
|
||
assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 2), ['a', 'b', '… und 1 weitere'])
|
||
assert.deepEqual(kuerzeListe([], 5), [])
|
||
})
|
||
|
||
test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N weitere, Leistungen stehen vollstaendig', () => {
|
||
const e = fixtureEinstellung()
|
||
e.abwesenheiten = Array.from({ length: 30 }, (_, i) => ({
|
||
id: `a_x${String(i).padStart(7, '0')}`, ressourceId: null, von: `2027-01-${String(i + 1).padStart(2, '0')}`, bis: `2027-01-${String(i + 1).padStart(2, '0')}`, titel: `Tag ${i + 1}`,
|
||
}))
|
||
e.sonderoeffnungen = Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`2027-02-${String(i + 1).padStart(2, '0')}`, [['10:00', '12:00']]]))
|
||
e.leistungen = Array.from({ length: 50 }, (_, i) => ({ ...e.leistungen[0], id: `s_l${String(i).padStart(6, '0')}`, name: `Leistung ${i + 1}` }))
|
||
const r = erzeuge({ einstellung: e })
|
||
const z = zeilen(abschnitt(r, 'ressourcen').text)
|
||
assert.equal(z.filter((x) => /^- \d\d\.01\.2027/.test(x)).length, 20)
|
||
assert.ok(z.includes('- … und 10 weitere'), z.join('\n'))
|
||
assert.equal(z.filter((x) => /^- \d\d\.02\.2027/.test(x)).length, 20)
|
||
assert.ok(z.includes('- … und 5 weitere'), z.join('\n'))
|
||
const l = zeilen(abschnitt(r, 'leistungen').text)
|
||
assert.equal(l[0], '50 Leistungen (50 aktiv):')
|
||
assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50)
|
||
})
|
||
|
||
// ================================================================ Generator einbauanleitung() 2 (Task D3b)
|
||
// Einbau, Snippet, CSS, Datenschutz-Absaetze, Checkliste, Abrechnung, Warnungen, Volltext.
|
||
|
||
const ALLE_IDS = [
|
||
'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen',
|
||
'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung',
|
||
]
|
||
const KOPIERBAR = ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du']
|
||
const KNOPF_FRAGEBOGEN = {
|
||
einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht, wie die Seite', bemerkung: '<script>alert(1)</script>' },
|
||
}
|
||
const PAKET_FEATURE = { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup', unlockedByPurchase: true }
|
||
const hatText = (ergebnis, ...teile) => teile.every((t) => ergebnis.text.includes(t))
|
||
|
||
test('D3b: die 13 Abschnitt-IDs in fester Reihenfolge, kopierbar nur Snippet, CSS und beide Datenschutz-Absaetze', () => {
|
||
const r = erzeuge()
|
||
assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS)
|
||
for (const a of r.abschnitte) {
|
||
assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
|
||
assert.equal(a.kopierbar, KOPIERBAR.includes(a.id), a.id)
|
||
assert.ok(a.titel && a.text, a.id)
|
||
}
|
||
assert.equal(new Set(r.abschnitte.map((a) => a.titel)).size, 13, 'jede Ueberschrift kommt einmal vor')
|
||
})
|
||
|
||
test('D3b: ENTWURF_BANNER hat den Wortlaut aus dem Brief', () => {
|
||
assert.equal(ENTWURF_BANNER, 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen')
|
||
})
|
||
|
||
test('D3b Volltext: genau eine Datenschutz-Fassung, die zur Anrede passt; abschnitte enthalten beide', () => {
|
||
const sie = fixtureEinstellung()
|
||
const du = fixtureEinstellung()
|
||
du.anrede = 'du'
|
||
const textSie = datenschutzAbsatz(sie, 'sie')
|
||
const textDu = datenschutzAbsatz(sie, 'du')
|
||
assert.notEqual(textSie, textDu)
|
||
|
||
const a = erzeuge({ einstellung: sie })
|
||
assert.equal(abschnitt(a, 'datenschutz_sie').text, textSie)
|
||
assert.equal(abschnitt(a, 'datenschutz_du').text, datenschutzAbsatz(sie, 'du'))
|
||
assert.ok(a.text.includes(textSie) && !a.text.includes(textDu), 'Sie-Betrieb: nur die Sie-Fassung')
|
||
assert.ok(a.text.includes(`## ${abschnitt(a, 'datenschutz_sie').titel}`) && !a.text.includes(`## ${abschnitt(a, 'datenschutz_du').titel}`))
|
||
|
||
const b = erzeuge({ einstellung: du })
|
||
assert.deepEqual(b.abschnitte.map((x) => x.id), ALLE_IDS, 'die Abschnitte bleiben gleich, nur der Volltext waehlt')
|
||
assert.ok(b.text.includes(datenschutzAbsatz(du, 'du')) && !b.text.includes(datenschutzAbsatz(du, 'sie')), 'Du-Betrieb: nur die Du-Fassung')
|
||
assert.equal(abschnitt(b, 'datenschutz_sie').text, datenschutzAbsatz(du, 'sie'))
|
||
// jeder andere Abschnitt steht im Volltext, in der Reihenfolge der Abschnitte
|
||
const ids = b.abschnitte.filter((x) => x.id !== 'datenschutz_sie').map((x) => `## ${x.titel}`)
|
||
assert.deepEqual(zeilen(b.text).filter((z) => z.startsWith('## ')), ids)
|
||
// eine unbekannte Anrede gilt wie in D3a als Sie
|
||
const seltsam = fixtureEinstellung()
|
||
seltsam.anrede = 'Herr'
|
||
assert.ok(erzeuge({ einstellung: seltsam }).text.includes(datenschutzAbsatz(seltsam, 'sie')))
|
||
})
|
||
|
||
test('D3b Datenschutz-Absatz: kein ENTWURF-Banner im kopierbaren Text, die Betriebs-E-Mail steht nur dort', () => {
|
||
const r = erzeuge({ entwurf: true })
|
||
for (const id of ['datenschutz_sie', 'datenschutz_du']) {
|
||
const t = abschnitt(r, id).text
|
||
assert.ok(!t.includes('ENTWURF'), id)
|
||
assert.ok(t.startsWith('Online-Terminbuchung'), id)
|
||
assert.ok(t.includes(BETRIEB.email), id)
|
||
}
|
||
})
|
||
|
||
test('D3b Snippet und CSS: genau das, was die Bausteine liefern, ohne Fragebogen im Modus inline', () => {
|
||
const r = erzeuge()
|
||
const erwartet = einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'inline' })
|
||
assert.equal(abschnitt(r, 'snippet').text, erwartet)
|
||
assert.ok(erwartet.includes('data-modus="inline"') && !erwartet.includes('data-knopf'))
|
||
assert.ok(erwartet.includes(`data-webklar-termine="${SITE_KEY}"`) && erwartet.includes(`data-api="${config.publicBaseUrl}"`))
|
||
assert.ok(erwartet.includes('<p>Termine telefonisch unter 0631 123456.</p>'), 'der Ausweichtext nennt die Telefonnummer des Betriebs')
|
||
assert.equal(abschnitt(r, 'css').text, cssVorschlag('#9d4b6f').css)
|
||
assert.ok(abschnitt(r, 'css').text.includes('--wkt-akzent:#9d4b6f;'))
|
||
// Fragebogen ohne Modus oder mit unbekanntem Modus: inline
|
||
for (const fragebogen of [{ einbau: { modus: null } }, { einbau: { modus: 'dialog' } }, { einbau: 'kaputt' }, {}, 'text']) {
|
||
assert.equal(abschnitt(erzeuge({ fragebogen }), 'snippet').text, erwartet, JSON.stringify(fragebogen))
|
||
}
|
||
// die Portal-Adresse wird beim Aufruf gelesen
|
||
const vorher = config.publicBaseUrl
|
||
try {
|
||
config.publicBaseUrl = 'https://portal.test.example'
|
||
assert.ok(abschnitt(erzeuge(), 'snippet').text.includes('data-api="https://portal.test.example"'))
|
||
} finally {
|
||
config.publicBaseUrl = vorher
|
||
}
|
||
})
|
||
|
||
test('D3b Snippet: Telefonnummer und siteKey sind maskiert (kein Ausbrechen aus dem Platzhalter)', () => {
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.telefon = '0631 <b>12</b>'
|
||
const r = erzeuge({ einstellung: e, feature: { ...PAKET_FEATURE, siteKey: 'a"><script>x' } })
|
||
const snippet = abschnitt(r, 'snippet').text
|
||
assert.ok(snippet.includes('<p>Termine telefonisch unter 0631 <b>12</b>.</p>'), snippet)
|
||
assert.ok(snippet.includes('data-webklar-termine="a"><script>x"'), snippet)
|
||
})
|
||
|
||
test('D3b Kundenwunsch: Knopf im Snippet, Wuensche nur als Text im Einbau-Abschnitt, nie in Snippet und CSS', () => {
|
||
const r = erzeuge({ fragebogen: KNOPF_FRAGEBOGEN })
|
||
const snippet = abschnitt(r, 'snippet').text
|
||
assert.ok(snippet.includes('data-modus="knopf" data-knopf="Termin sichern"'), snippet)
|
||
assert.equal(snippet, einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'knopf', knopfText: 'Termin sichern' }))
|
||
|
||
const einbau = abschnitt(r, 'einbau').text
|
||
assert.ok(einbau.includes('Kundenwunsch (Text des Kunden)'), einbau)
|
||
for (const wunsch of ['Termin sichern', 'Startseite unter dem Hero', 'Schlicht, wie die Seite', '<script>alert(1)</script>']) {
|
||
assert.ok(einbau.includes(wunsch), `${wunsch}\n---\n${einbau}`)
|
||
}
|
||
assert.equal((einbau.match(/Kundenwunsch \(Text des Kunden\)/g) || []).length, 1, 'ein einziger Block')
|
||
// Stelle, Gestaltung und Bemerkung stehen in keinem anderen Abschnitt (der Knopf-Text nur zusaetzlich im Snippet)
|
||
for (const a of r.abschnitte.filter((x) => x.id !== 'einbau')) {
|
||
for (const wunsch of ['Startseite unter dem Hero', 'Schlicht, wie die Seite', 'alert(1)']) assert.ok(!a.text.includes(wunsch), `${wunsch} in ${a.id}`)
|
||
}
|
||
assert.ok(!snippet.includes('alert(1)') && !abschnitt(r, 'css').text.includes('alert(1)'))
|
||
assert.equal((snippet.match(/<script/g) || []).length, 1, 'im Snippet steht nur das eigene Skript-Element')
|
||
// der Modus steht auch im Einbau-Abschnitt
|
||
assert.ok(/Modus: knopf/.test(einbau), einbau)
|
||
assert.ok(/Modus: inline/.test(abschnitt(erzeuge(), 'einbau').text))
|
||
})
|
||
|
||
test('D3b Kundenwunsch: ohne Fragebogen steht, dass es keine Angaben gibt; der Knopf-Text zaehlt nur im Modus knopf', () => {
|
||
const ohne = abschnitt(erzeuge(), 'einbau').text
|
||
assert.ok(ohne.includes('Kundenwunsch (Text des Kunden): keine Angaben'), ohne)
|
||
const inline = erzeuge({ fragebogen: { einbau: { modus: 'inline', knopfText: 'Buchen', stelle: 'Unten', gestaltung: '', bemerkung: '' } } })
|
||
assert.ok(!abschnitt(inline, 'snippet').text.includes('data-knopf'), 'inline: kein Knopf-Text im Snippet')
|
||
assert.ok(abschnitt(inline, 'einbau').text.includes('Unten'))
|
||
})
|
||
|
||
test('D3b Modus: ohne gueltige Wahl gilt inline und der Einbau-Abschnitt sagt, dass es der Standard ist', () => {
|
||
for (const fragebogen of [null, { einbau: { modus: 'dialog' } }, { einbau: { modus: 'KNOPF' } }, { einbau: { modus: null } }]) {
|
||
const r = erzeuge({ fragebogen })
|
||
const einbau = abschnitt(r, 'einbau').text
|
||
assert.ok(einbau.includes('Modus: inline – die Buchungsstrecke steht direkt in der Seite (Standard, der Fragebogen nennt keinen Modus)'), `${JSON.stringify(fragebogen)}\n${einbau}`)
|
||
assert.ok(!einbau.includes('undefined') && !/--wkt-akzent immer/.test(abschnitt(r, 'checkliste').text))
|
||
}
|
||
const gewaehlt = abschnitt(erzeuge({ fragebogen: { einbau: { modus: 'inline' } } }), 'einbau').text
|
||
assert.ok(gewaehlt.includes('(Wahl des Kunden im Fragebogen)'), gewaehlt)
|
||
assert.ok(abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'einbau').text.includes('Modus: knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog (Wahl des Kunden im Fragebogen)'))
|
||
})
|
||
|
||
test('D3b Kundenwunsch: die Grenzen 40/300/1000/1000 gelten erneut', () => {
|
||
const lang = (n) => 'ä'.repeat(n + 5)
|
||
const r = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: lang(40), stelle: lang(300), gestaltung: lang(1000), bemerkung: lang(1000) } } })
|
||
const einbau = abschnitt(r, 'einbau').text
|
||
const wert = (label) => zeilen(einbau).find((z) => z.trim().startsWith(`${label}: `)).trim().slice(label.length + 2)
|
||
assert.equal(wert('Knopf-Text'), 'ä'.repeat(40))
|
||
assert.equal(wert('Stelle'), 'ä'.repeat(300))
|
||
assert.equal(wert('Gestaltung'), 'ä'.repeat(1000))
|
||
assert.equal(wert('Bemerkung'), 'ä'.repeat(1000))
|
||
assert.equal(/data-knopf="([^"]*)"/.exec(abschnitt(r, 'snippet').text)[1], 'ä'.repeat(40))
|
||
// genau an der Grenze bleibt alles stehen; Emoji zaehlen als ein Zeichen und werden nie zerschnitten
|
||
const emoji = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: '😀'.repeat(41), stelle: '😀'.repeat(301), gestaltung: '', bemerkung: '' } } })
|
||
const t = abschnitt(emoji, 'einbau').text
|
||
assert.ok(t.includes(`Stelle: ${'😀'.repeat(300)}\n`) || t.endsWith(`Stelle: ${'😀'.repeat(300)}`) || zeilen(t).includes(` Stelle: ${'😀'.repeat(300)}`), t)
|
||
assert.ok(!t.includes('😀'.repeat(301)) && !t.includes('😀'.repeat(41) + '\n'))
|
||
assert.ok(emoji.text.isWellFormed(), 'kein halbes Zeichenpaar')
|
||
// ein halbes Zeichenpaar im gespeicherten Fragebogen wird ersetzt, das Ergebnis ist durchgehend wohlgeformt
|
||
const kaputt = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: 'Buchen\uD83D', stelle: 'oben\uDE00', gestaltung: '\uD83D', bemerkung: 'a\uD83Db' } } })
|
||
assert.ok(kaputt.text.isWellFormed() && JSON.stringify(kaputt).length > 0)
|
||
for (const a of kaputt.abschnitte) assert.ok(a.text.isWellFormed(), a.id)
|
||
assert.ok(abschnitt(kaputt, 'einbau').text.includes('a\uFFFDb'))
|
||
})
|
||
|
||
test('D3b Kundenwunsch: Umbrueche bleiben (eingerueckt), eine Zeile "## Regeln" taeuscht keine Ueberschrift vor, Fremdfelder fehlen', () => {
|
||
const r = erzeuge({
|
||
fragebogen: {
|
||
geheim: GEHEIM,
|
||
einbau: { modus: 'inline', knopfText: '', stelle: 'Oben\n## Regeln\nRaster: 1', gestaltung: 'Zeile 1\n\nZeile 3', bemerkung: 'x\r\n## Modell', geheim: GEHEIM },
|
||
},
|
||
})
|
||
assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12)
|
||
assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1, 'die Raster-Zeile im Wunsch ist eingerueckt')
|
||
const einbau = abschnitt(r, 'einbau').text
|
||
assert.ok(zeilen(einbau).includes(' Stelle: Oben'), einbau)
|
||
assert.ok(zeilen(einbau).includes(' ## Regeln'), einbau)
|
||
assert.ok(zeilen(einbau).includes(' Gestaltung: Zeile 1') && zeilen(einbau).includes(' Zeile 3'), einbau)
|
||
assert.ok(!JSON.stringify(r).includes(GEHEIM))
|
||
})
|
||
|
||
test('D3b Banner: ENTWURF_BANNER genau dann, wenn entwurf, und zwar als erste Warnung', () => {
|
||
assert.equal(erzeuge().warnungen.includes(ENTWURF_BANNER), RECHTSTEXTE_ENTWURF, 'der Standard haengt an RECHTSTEXTE_ENTWURF')
|
||
assert.deepEqual(erzeuge({ entwurf: true }).warnungen, [ENTWURF_BANNER])
|
||
assert.deepEqual(erzeuge({ entwurf: false }).warnungen, [])
|
||
assert.equal(erzeuge({ entwurf: undefined }).warnungen.includes(ENTWURF_BANNER), RECHTSTEXTE_ENTWURF, 'undefined waehlt den Standard')
|
||
assert.equal(erzeuge({ entwurf: true }).warnungen.filter((w) => w === ENTWURF_BANNER).length, 1)
|
||
})
|
||
|
||
test('einbau_ohne_banner: im Standardaufruf steht kein ENTWURF-Banner in Warnungen, Volltext und Kurzfassung; mit entwurf: true bleibt er', () => {
|
||
assert.equal(RECHTSTEXTE_ENTWURF, false)
|
||
const r = erzeuge()
|
||
assert.deepEqual(r.warnungen, [])
|
||
assert.ok(!r.warnungen.includes(ENTWURF_BANNER))
|
||
assert.ok(!r.text.includes('ENTWURF'), 'Volltext')
|
||
assert.ok(!r.kurz.includes('ENTWURF'), 'Kurzfassung (Ticket)')
|
||
for (const a of r.abschnitte) assert.ok(!a.text.includes('ENTWURF'), `Abschnitt ${a.id}`)
|
||
// die D3b-Tests mit ausdrücklichem entwurf: true bleiben gültig: der Banner ist weiter eine eigene Warnung
|
||
const mit = erzeuge({ entwurf: true })
|
||
assert.deepEqual(mit.warnungen, [ENTWURF_BANNER])
|
||
assert.ok(mit.kurz.includes(ENTWURF_BANNER))
|
||
})
|
||
|
||
test('checkliste_datenschutz_endregel: die Checkliste nennt die Endregel der Leistungsbeschreibung, die Zwischenregel ist weg', () => {
|
||
const text = abschnitt(erzeuge(), 'checkliste').text
|
||
const punkt = zeilen(text).find((z) => z.includes('Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung'))
|
||
assert.ok(punkt, text)
|
||
assert.ok(punkt.includes(`(Fassung Sie; ${DATENSCHUTZ_ENDREGEL}).`), punkt)
|
||
for (const teil of ['von WEBklar betreuten Website', 'fügen wir den Absatz beim Einbau ein', 'nicht auf das Stundenkontingent angerechnet', 'Textbaustein per Chat an den Kunden']) {
|
||
assert.ok(punkt.includes(teil), `Endregel: ${teil}`)
|
||
}
|
||
assert.ok(!text.includes('liegt die Datenschutzerklärung nicht in unserem Repo'), 'Zwischenregel aus D3b ist weg')
|
||
assert.ok(!erzeuge().kurz.includes('nicht in unserem Repo'), 'auch nicht in der Kurzfassung')
|
||
assert.ok(!erzeuge().text.includes('nicht in unserem Repo'), 'und nicht im Volltext')
|
||
const du = fixtureEinstellung()
|
||
du.anrede = 'du'
|
||
assert.ok(abschnitt(erzeuge({ einstellung: du }), 'checkliste').text.includes(`(Fassung Du; ${DATENSCHUTZ_ENDREGEL}).`))
|
||
})
|
||
|
||
test('D3b Warnungen: fehlende Telefonnummer, und Anschrift nur, wenn Gaeste ein Widerrufsrecht haben', () => {
|
||
const ohneTelefon = fixtureEinstellung()
|
||
ohneTelefon.betrieb.telefon = ''
|
||
const w = erzeuge({ einstellung: ohneTelefon, entwurf: false }).warnungen
|
||
assert.equal(w.length, 1, w.join('\n'))
|
||
assert.ok(/Telefon/.test(w[0]) && /nicht buchen/.test(w[0]), w[0])
|
||
|
||
const ohneAnschrift = fixtureEinstellung()
|
||
ohneAnschrift.betrieb.adresse = ' '
|
||
const v = erzeuge({ einstellung: ohneAnschrift, entwurf: false }).warnungen
|
||
assert.equal(v.length, 1, v.join('\n'))
|
||
assert.ok(/Anschrift/.test(v[0]) && /nicht buchen/.test(v[0]), v[0])
|
||
|
||
ohneAnschrift.recht = { gaesteSind: 'unternehmer', freizeit: false }
|
||
assert.deepEqual(erzeuge({ einstellung: ohneAnschrift, entwurf: false }).warnungen, [], 'Unternehmen: keine Widerrufsbelehrung, die Anschrift ist nicht Pflicht')
|
||
|
||
ohneTelefon.betrieb.adresse = ''
|
||
const beides = erzeuge({ einstellung: ohneTelefon, entwurf: false }).warnungen
|
||
assert.deepEqual(beides.map((x) => /Telefon|Anschrift/.exec(x)[0]), ['Telefon', 'Anschrift'], 'Telefon vor Anschrift')
|
||
})
|
||
|
||
test('D3b Warnungen: fehlende Impressum- oder Datenschutz-URL', () => {
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.impressumUrl = null
|
||
const a = erzeuge({ einstellung: e, entwurf: false }).warnungen
|
||
assert.equal(a.length, 1)
|
||
assert.ok(/Impressum/.test(a[0]) && /Link/.test(a[0]), a[0])
|
||
e.betrieb.datenschutzUrl = ''
|
||
const b = erzeuge({ einstellung: e, entwurf: false }).warnungen
|
||
assert.deepEqual(b.map((x) => /Impressum|Datenschutz/.exec(x)[0]), ['Impressum', 'Datenschutz'], 'Impressum vor Datenschutz')
|
||
e.betrieb.impressumUrl = 'https://salon-muster.example/impressum'
|
||
e.betrieb.datenschutzUrl = 'https://salon-muster.example/datenschutz'
|
||
assert.deepEqual(erzeuge({ einstellung: e, entwurf: false }).warnungen, [])
|
||
})
|
||
|
||
test('D3b Warnungen: Gesundheitsbereich (Liste gehoert C), als Klartext, wohlgeformt und einzeilig', () => {
|
||
const e = fixtureEinstellung()
|
||
e.leistungen[1].name = 'Physiotherapie'
|
||
const w = erzeuge({ einstellung: e, entwurf: false }).warnungen
|
||
assert.equal(w.length, 1, w.join('\n'))
|
||
assert.ok(w[0].includes('Physio'), w[0])
|
||
assert.ok(erzeuge({ einstellung: fixtureEinstellung(), entwurf: false }).warnungen.every((x) => !/Physio/.test(x)), 'ohne Treffer keine Meldung')
|
||
|
||
// Hinweis der Steuerung (C2): die Meldungen tragen Namen des Kunden, gekuerzt in UTF-16-Einheiten (halbes Zeichenpaar moeglich)
|
||
// und ungemaskiert. Sie bleiben Klartext (der Anzeiger setzt sie als Text ein), muessen aber wohlgeformt und einzeilig sein.
|
||
const f = fixtureEinstellung()
|
||
f.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
|
||
f.leistungen[0].beschreibung = 'Physiotherapie'
|
||
f.leistungen[1].name = 'Zeile1\n## Zeile2 <b>x</b>'
|
||
f.leistungen[1].beschreibung = 'Physiotherapie'
|
||
const roh = erzeuge({ einstellung: f, entwurf: false })
|
||
const lose = roh.warnungen.filter((x) => /Beschreibung der Leistung/.test(x))
|
||
assert.equal(lose.length, 2, roh.warnungen.join('\n'))
|
||
for (const x of roh.warnungen) {
|
||
assert.ok(x.isWellFormed(), JSON.stringify(x))
|
||
assert.ok(!/[\r\n]/.test(x), JSON.stringify(x))
|
||
}
|
||
assert.ok(lose.some((x) => x.includes('<b>x</b>')), 'Klartext: nichts wird maskiert oder entfernt')
|
||
assert.ok(lose.some((x) => x.includes('x'.repeat(38))), lose.join('\n'))
|
||
for (const a of roh.abschnitte.filter((x) => x.id === 'snippet' || x.id === 'css')) assert.ok(!a.text.includes('<b>'), a.id)
|
||
assert.ok(!roh.text.includes('Beschreibung der Leistung'), 'die Warnungen stehen nicht im Volltext')
|
||
})
|
||
|
||
test('D3b Warnungen: Kontrasthinweise aus cssVorschlag', () => {
|
||
const e = fixtureEinstellung()
|
||
e.farbe = '#ffff00'
|
||
const erwartet = cssVorschlag('#ffff00').hinweise
|
||
assert.equal(erwartet.length, 1)
|
||
assert.deepEqual(erzeuge({ einstellung: e, entwurf: false }).warnungen, erwartet)
|
||
assert.deepEqual(erzeuge({ entwurf: false }).warnungen, [], 'die Farbe der Fixture besteht')
|
||
})
|
||
|
||
test('D3b Datenschutz-Absatz nicht erzeugbar: kein Wurf, Hinweis statt Text im Abschnitt, eine Warnung', () => {
|
||
for (const luecke of [{ email: '' }, { email: undefined }, { name: '' }, { name: ' ', email: null }]) {
|
||
const e = fixtureEinstellung()
|
||
Object.assign(e.betrieb, luecke)
|
||
const r = erzeuge({ einstellung: e, entwurf: false })
|
||
assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS, JSON.stringify(luecke))
|
||
for (const id of ['datenschutz_sie', 'datenschutz_du']) {
|
||
const a = abschnitt(r, id)
|
||
assert.ok(/nicht erzeugt/.test(a.text) && /E-Mail/.test(a.text), a.text)
|
||
assert.ok(!a.text.includes('Online-Terminbuchung'), 'es ist kein Absatz fuer die Website, nur ein Hinweis')
|
||
assert.equal(a.kopierbar, false, `${id}: ein Hinweis wird nie als Datenschutz-Absatz zum Kopieren angeboten`)
|
||
}
|
||
assert.equal(r.warnungen.length, 1, r.warnungen.join('\n'))
|
||
assert.ok(/Datenschutz-Absatz nicht erzeugbar/.test(r.warnungen[0]), r.warnungen[0])
|
||
assert.ok(r.text.includes('nicht erzeugt'), 'der Hinweis steht im Volltext')
|
||
}
|
||
})
|
||
|
||
test('D3b Warnungen: feste Reihenfolge Banner, Pflichtangaben, URLs, Gesundheit, Kontrast, Datenschutz', () => {
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.telefon = ''
|
||
e.betrieb.adresse = ''
|
||
e.betrieb.impressumUrl = ''
|
||
e.betrieb.datenschutzUrl = ''
|
||
e.betrieb.email = ''
|
||
e.farbe = '#ffff00'
|
||
e.leistungen[1].name = 'Physiotherapie'
|
||
const w = erzeuge({ einstellung: e, entwurf: true }).warnungen
|
||
const treffer = [ENTWURF_BANNER, /Telefon/, /Anschrift/, /Impressum/, /Datenschutz-URL/, /Physio/, /Kontrast gegen Weiß/, /Datenschutz-Absatz nicht erzeugbar/]
|
||
const stellen = treffer.map((t) => w.findIndex((x) => (typeof t === 'string' ? x === t : t.test(x))))
|
||
assert.ok(stellen.every((i) => i >= 0), `${JSON.stringify(stellen)}\n${w.join('\n')}`)
|
||
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), w.join('\n'))
|
||
assert.equal(w.length, 8)
|
||
})
|
||
|
||
test('D3b Checkliste: immer-Punkte (Freischalten, Probebuchung, stornieren, Kontrast, 390 px, Tastatur, Datenschutz, URLs)', () => {
|
||
const text = abschnitt(erzeuge(), 'checkliste').text
|
||
const z = zeilen(text)
|
||
assert.ok(z.every((x) => x.startsWith('- [ ] ')), 'jeder Punkt ist ein Kaestchen')
|
||
for (const erwartet of [
|
||
'Freischalten (Abnahme)', 'Fast geschafft', 'stornieren', 'öffentliche Schnittstelle', 'unsichtbar', 'Ausweichtext',
|
||
'Datenschutz-Absatz', 'Impressum', 'Kontrast', '390 px', 'Tastatur',
|
||
]) assert.ok(text.includes(erwartet), `${erwartet}\n---\n${text}`)
|
||
assert.ok(text.includes(BETRIEB.impressumUrl) && text.includes(BETRIEB.datenschutzUrl), 'die URLs stehen zum Pruefen im Punkt')
|
||
assert.ok(text.includes(DATENSCHUTZ_ENDREGEL), 'Endregel der Leistungsbeschreibung (Nr. 4 Abs. 9)')
|
||
assert.ok(text.includes(`${config.womsBaseUrl}/customers/c1?tab=funktionen`), 'Link ins WOMS-Kundenpanel')
|
||
// die Reihenfolge: erst Einbau, dann Freischalten, dann Probebuchung, dann die Pruefungen am sichtbaren Widget
|
||
const stelle = (teil) => z.findIndex((x) => x.includes(teil))
|
||
assert.ok(stelle('Einbau-Code') < stelle('Freischalten (Abnahme)') && stelle('Freischalten (Abnahme)') < stelle('Fast geschafft'), text)
|
||
assert.ok(stelle('Fast geschafft') < stelle('Kontrast') && stelle('Kontrast') < stelle('390 px') && stelle('390 px') < stelle('Tastatur'), text)
|
||
})
|
||
|
||
test('D3b Checkliste: der WOMS-Link wird beim Aufruf gelesen und fehlt ohne Kunden- und Projekt-ID', () => {
|
||
const vorher = config.womsBaseUrl
|
||
try {
|
||
config.womsBaseUrl = 'https://woms.test.example'
|
||
assert.ok(abschnitt(erzeuge(), 'checkliste').text.includes('https://woms.test.example/customers/c1?tab=funktionen'))
|
||
} finally {
|
||
config.womsBaseUrl = vorher
|
||
}
|
||
const text = abschnitt(erzeuge({ feature: { siteKey: SITE_KEY } }), 'checkliste').text
|
||
assert.ok(text.includes('Freischalten (Abnahme)') && !text.includes('/customers/'), text)
|
||
})
|
||
|
||
test('D3b Checkliste: Verbraucher haben die Widerrufspunkte, Unternehmen keinen einzigen', () => {
|
||
const verbraucher = abschnitt(erzeuge(), 'checkliste').text
|
||
for (const erwartet of ['Widerrufsbelehrung', 'aufklappbar', 'vorzeitigen Beginn', 'Vertrag widerrufen']) {
|
||
assert.ok(verbraucher.includes(erwartet), `${erwartet}\n---\n${verbraucher}`)
|
||
}
|
||
for (const gaesteSind of ['unternehmer']) {
|
||
const e = fixtureEinstellung()
|
||
e.recht = { gaesteSind, freizeit: false }
|
||
const t = abschnitt(erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
|
||
assert.ok(!/Widerruf|widerruf|vorzeitig/.test(t), `${gaesteSind}\n---\n${t}`)
|
||
}
|
||
const freizeit = fixtureEinstellung()
|
||
freizeit.recht = { gaesteSind: 'verbraucher', freizeit: true }
|
||
assert.ok(!/Widerruf|widerruf/.test(abschnitt(erzeuge({ einstellung: freizeit }), 'checkliste').text), 'Freizeitleistung: kein Widerrufsrecht')
|
||
// keine sichtbare Leistung: auch dann keine Widerrufspunkte (zeigtWiderruf)
|
||
const leer = fixtureEinstellung()
|
||
leer.leistungen.forEach((l) => { l.aktiv = false })
|
||
assert.ok(!/Widerruf|widerruf/.test(abschnitt(erzeuge({ einstellung: leer }), 'checkliste').text))
|
||
})
|
||
|
||
test('D3b Checkliste: der Haken fuer den vorzeitigen Beginn erscheint nur bei Leistungen mit Preis', () => {
|
||
const e = fixtureEinstellung()
|
||
assert.ok(abschnitt(erzeuge({ einstellung: e }), 'checkliste').text.includes('vorzeitigen Beginn'))
|
||
e.leistungen.forEach((l) => { l.preisArt = 'keiner'; l.preisCent = 0 })
|
||
const t = abschnitt(erzeuge({ einstellung: e }), 'checkliste').text
|
||
assert.ok(!t.includes('vorzeitigen Beginn'), t)
|
||
assert.ok(t.includes('Widerrufsbelehrung') && t.includes('Vertrag widerrufen'), 'die uebrigen Widerrufspunkte bleiben')
|
||
})
|
||
|
||
test('D3b Checkliste: Verbraucher mit Modus knopf haben den dauerhaften Widerrufsknopf, mit inline nicht', () => {
|
||
const knopf = abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
|
||
assert.ok(knopf.includes('Widerrufsknopf dauerhaft neben dem Buchungsknopf'), knopf)
|
||
const inline = abschnitt(erzeuge({ fragebogen: { einbau: { modus: 'inline' } } }), 'checkliste').text
|
||
const ohne = abschnitt(erzeuge(), 'checkliste').text
|
||
for (const t of [inline, ohne]) {
|
||
assert.ok(!t.includes('dauerhaft neben dem Buchungsknopf') && !t.includes('Widerrufsknopf'), t)
|
||
assert.ok(t.includes('Vertrag widerrufen'), 'der Knopf im Fuss bleibt')
|
||
}
|
||
})
|
||
|
||
test('D3b Checkliste: Modus knopf verlangt --wkt-akzent immer, inline nicht', () => {
|
||
const knopf = abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
|
||
assert.ok(/--wkt-akzent immer setzen/.test(knopf), knopf)
|
||
// auch ohne Widerrufsrecht
|
||
const e = fixtureEinstellung()
|
||
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
|
||
const ohneWiderruf = abschnitt(erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
|
||
assert.ok(/--wkt-akzent immer setzen/.test(ohneWiderruf) && !/Widerruf/.test(ohneWiderruf), ohneWiderruf)
|
||
assert.ok(!/--wkt-akzent/.test(abschnitt(erzeuge(), 'checkliste').text))
|
||
})
|
||
|
||
test('D3b Abrechnung: bezahlt mit Rechnung, bezahlt ohne Rechnung, ohne Zahlung freigeschaltet', () => {
|
||
const rechnung = 'https://invoice.stripe.com/i/acct_1/test_abc'
|
||
const mit = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, invoiceUrl: rechnung } }), 'abrechnung').text
|
||
assert.ok(mit.includes(`Einrichtung bezahlt (149 €, Rechnung ${rechnung}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden`), mit)
|
||
assert.ok(mit.includes('ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen'), mit)
|
||
assert.ok(!mit.includes('Rechnung folgt'))
|
||
|
||
for (const invoiceUrl of [undefined, null, '', ' ', 'javascript:alert(1)', 'http://invoice.example/x', 'kein url']) {
|
||
const ohne = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, invoiceUrl } }), 'abrechnung').text
|
||
assert.ok(ohne.includes('Einrichtung bezahlt (149 €, Rechnung folgt im Portal), Zeit im Arbeitsblatt, nicht auf Abo-Stunden'), `${invoiceUrl}\n${ohne}`)
|
||
assert.ok(ohne.includes('Leistungsumfang') && !ohne.includes('javascript:'), ohne)
|
||
}
|
||
|
||
for (const unlockedByPurchase of [false, undefined, null, 'true', 1]) {
|
||
const pilot = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, unlockedByPurchase, invoiceUrl: rechnung } }), 'abrechnung').text
|
||
assert.ok(pilot.includes('ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung'), `${unlockedByPurchase}\n${pilot}`)
|
||
assert.ok(!pilot.includes('149 €') && !pilot.includes(rechnung) && !pilot.includes('Abo-Stunden'), pilot)
|
||
}
|
||
const pilot = abschnitt(erzeuge({ feature: null }), 'abrechnung').text
|
||
assert.ok(pilot.includes('keine Abrechnung'), 'ohne Funktionsdokument: keine Abrechnung')
|
||
})
|
||
|
||
test('D3b ohne Einstellung: nur der Abschnitt snippet (Modus aus dem Fragebogen), die Warnung, ein Volltext aus dem Snippet', () => {
|
||
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: false })
|
||
assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'])
|
||
assert.equal(r.abschnitte[0].kopierbar, true)
|
||
assert.deepEqual(r.warnungen, ['Keine Einstellungen gespeichert'])
|
||
assert.equal(r.abschnitte[0].text, einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: undefined, modus: 'inline' }))
|
||
assert.ok(r.abschnitte[0].text.includes('Termine telefonisch vereinbaren.'), 'ohne Einstellung gibt es keine Telefonnummer')
|
||
assert.equal(r.text, `## ${r.abschnitte[0].titel}\n${r.abschnitte[0].text}`)
|
||
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
|
||
assert.ok(r.kurz.includes(r.abschnitte[0].text), 'die Kurzfassung (D4) traegt auch hier das Snippet')
|
||
|
||
const knopf = erzeuge({ einstellung: null, fragebogen: KNOPF_FRAGEBOGEN, version: 0, entwurf: false })
|
||
assert.ok(knopf.abschnitte[0].text.includes('data-modus="knopf" data-knopf="Termin sichern"'), knopf.abschnitte[0].text)
|
||
assert.ok(!knopf.text.includes('alert(1)') && !knopf.text.includes('Startseite unter dem Hero'), 'die Wuensche stehen auch hier nicht im Snippet')
|
||
|
||
// das Banner gilt unabhaengig von den Einstellungen und steht zuerst
|
||
assert.deepEqual(erzeuge({ einstellung: null, entwurf: true }).warnungen, [ENTWURF_BANNER, 'Keine Einstellungen gespeichert'])
|
||
// kein Objekt gilt wie null
|
||
for (const einstellung of [undefined, 'text', 42, []]) assert.deepEqual(erzeuge({ einstellung, entwurf: false }).abschnitte.map((a) => a.id), ['snippet'], JSON.stringify(einstellung))
|
||
})
|
||
|
||
test('D3b ist deterministisch, veraendert Eingaben nicht und gibt nie ein Token aus', () => {
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.email = ''
|
||
e.leistungen[1].name = 'Physiotherapie'
|
||
const fragebogen = structuredClone(KNOPF_FRAGEBOGEN)
|
||
const feature = { ...PAKET_FEATURE, invoiceUrl: 'https://invoice.stripe.com/i/x' }
|
||
const vorher = structuredClone({ e, fragebogen, feature })
|
||
const a = erzeuge({ einstellung: e, fragebogen, feature })
|
||
const b = erzeuge({ einstellung: e, fragebogen, feature })
|
||
assert.deepEqual(a, b)
|
||
assert.deepEqual(structuredClone({ e, fragebogen, feature }), vorher)
|
||
const json = JSON.stringify(a)
|
||
for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken))
|
||
assert.ok(!/feedtoken/i.test(json))
|
||
})
|
||
|
||
test('D3b Volltext hat die Abschnitte in der Reihenfolge der Tabelle und nur Text (keine HTML-Maskierung der Kundentexte)', () => {
|
||
const e = fixtureEinstellung()
|
||
e.ressourcen[0].name = 'Anna <b>&</b>'
|
||
const r = erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN })
|
||
assert.ok(r.text.includes('Anna <b>&</b>'))
|
||
assert.ok(hatText(r, abschnitt(r, 'snippet').text, abschnitt(r, 'css').text, abschnitt(r, 'checkliste').text, abschnitt(r, 'abrechnung').text, abschnitt(r, 'einbau').text))
|
||
const stellen = r.abschnitte.filter((a) => a.id !== 'datenschutz_du').map((a) => r.text.indexOf(`## ${a.titel}\n`))
|
||
assert.ok(stellen.every((i) => i >= 0))
|
||
assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
|
||
})
|
||
|
||
// ================================================================ Kurzfassung fuers Ticket, ticketStand, driftWarnung (Task D4)
|
||
// `kurz` ist Klartext fuer ticket.details (WOMS zeigt pre-wrap): Absaetze durch Leerzeile, erster Absatz = Kopf, hoechstens
|
||
// TICKET_DETAILS_MAX Zeichen, nie ein Datenschutz-Absatz und nie ein Token.
|
||
|
||
const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
|
||
const absaetze = (text) => text.split('\n\n')
|
||
const absatzMit = (kurz, anfang) => absaetze(kurz).find((a) => a.startsWith(anfang))
|
||
const LINK = anleitungsLink('c1', 'p1')
|
||
const GESUNDHEIT_KOPF = 'Gesundheitsbereich prüfen:'
|
||
|
||
/** Betrieb mit sechs Warnungen (Banner kommt ueber entwurf: true): Telefon, Anschrift, Impressum, Datenschutz-URL, Kontrast. */
|
||
function mitSechsWarnungen() {
|
||
const e = fixtureEinstellung()
|
||
e.betrieb.telefon = ''
|
||
e.betrieb.adresse = ''
|
||
e.betrieb.impressumUrl = ''
|
||
e.betrieb.datenschutzUrl = ''
|
||
e.farbe = '#ffff00'
|
||
return e
|
||
}
|
||
|
||
/** Maximalbefuellung: 50 Leistungen mit 100 Zeichen (die ersten zehn mit Gesundheitswort), 20 Personen, 5 Zusatzfelder, sechs Warnungen. */
|
||
function maximalEinstellung() {
|
||
const e = mitSechsWarnungen()
|
||
const vorlage = e.leistungen[0]
|
||
e.leistungen = Array.from({ length: 50 }, (_, i) => ({
|
||
...vorlage,
|
||
id: `s_max${String(i).padStart(5, '0')}`,
|
||
name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`,
|
||
aktiv: true,
|
||
ressourcen: [],
|
||
beschreibung: i < 10 ? 'Physiotherapie' : '',
|
||
}))
|
||
e.ressourcen = Array.from({ length: 20 }, (_, i) => ({
|
||
id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
|
||
}))
|
||
e.gastFelder = {
|
||
telefon: 'pflicht',
|
||
nachricht: 'optional',
|
||
zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })),
|
||
}
|
||
return e
|
||
}
|
||
const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } }
|
||
|
||
test('D4 kurz: der erste Absatz ist der Kopf und endet mit der Konfigurationsstand-Zeile', () => {
|
||
const r = erzeuge({ entwurf: false })
|
||
const [kopf] = absaetze(r.kurz)
|
||
assert.deepEqual(kopf.split('\n'), [
|
||
'Funktion: Terminplaner',
|
||
'Projekt: Salon Muster Website',
|
||
'Domain: salon-muster.example',
|
||
'Kunde: Salon Muster GbR',
|
||
`siteKey: ${SITE_KEY}`,
|
||
'Konfigurationsstand: Version 3, 03.10.2026',
|
||
])
|
||
assert.equal(ticketStand(r.kurz), 3, 'ticketStand liest die erzeugte Zeile zurueck')
|
||
})
|
||
|
||
test('D4 kurz Kopf: Domain wie createFeatureTicket (liveDomain, sonst previewUrl, sonst subdomain), Projektname faellt auf die subdomain zurueck', () => {
|
||
const kopf = (project) => absaetze(erzeuge({ project }).kurz)[0].split('\n').slice(1, 3)
|
||
assert.deepEqual(kopf(PROJEKT), ['Projekt: Salon Muster Website', 'Domain: salon-muster.example'])
|
||
assert.deepEqual(kopf({ projectName: 'P', subdomain: 's', previewUrl: 'https://s.project.webklar.com' }), ['Projekt: P', 'Domain: https://s.project.webklar.com'])
|
||
assert.deepEqual(kopf({ projectName: 'P', subdomain: 's' }), ['Projekt: P', 'Domain: s'])
|
||
assert.deepEqual(kopf({ subdomain: 's' }), ['Projekt: s', 'Domain: s'])
|
||
assert.deepEqual(kopf(undefined), ['Projekt: ', 'Domain: '])
|
||
// lange Werte und Umbrueche: eine Zeile, hoechstens 120 Zeichen
|
||
const lang = kopf({ projectName: `Name\nmit\tUmbruch ${'x'.repeat(200)}`, liveDomain: 'd'.repeat(300) })
|
||
assert.ok(lang[0].startsWith('Projekt: Name mit Umbruch xxx'), lang[0])
|
||
assert.ok(lang[0].length <= 'Projekt: '.length + 120 && lang[0].endsWith('…'), lang[0])
|
||
assert.ok(lang[1].length <= 'Domain: '.length + 120 && lang[1].endsWith('…'), lang[1])
|
||
})
|
||
|
||
test('D4 kurz Konfigurationsstand: Version und Datum, ohne Datum nur die Version, ohne Einstellungen Version 0, unlesbare Einstellungen', () => {
|
||
const stand = (zusatz) => absaetze(erzeuge(zusatz).kurz)[0].split('\n').at(-1)
|
||
assert.equal(stand({}), 'Konfigurationsstand: Version 3, 03.10.2026')
|
||
assert.equal(stand({ updatedAt: null }), 'Konfigurationsstand: Version 3')
|
||
assert.equal(stand({ updatedAt: 'kein Datum' }), 'Konfigurationsstand: Version 3')
|
||
assert.equal(stand({ updatedAt: '2026-12-31T23:30:00.000Z' }), 'Konfigurationsstand: Version 3, 01.01.2027', 'Berliner Kalendertag')
|
||
assert.equal(stand({ einstellung: null, version: 0, updatedAt: null }), 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)')
|
||
assert.equal(stand({ einstellung: null, version: 4, updatedAt: '2026-10-03T08:15:00.000Z' }), 'Konfigurationsstand: Version 4 (Einstellungen nicht lesbar)')
|
||
assert.equal(ticketStand(erzeuge({ einstellung: null, version: 0 }).kurz), 0)
|
||
})
|
||
|
||
test('D4 kurz: Snippet, 6 Schritte in fester Reihenfolge, Verweis; kein Datenschutz-Absatz, kein Token', () => {
|
||
const e = fixtureEinstellung()
|
||
const r = erzeuge({ einstellung: e, entwurf: false, feature: PAKET_FEATURE })
|
||
const snippet = abschnitt(r, 'snippet').text
|
||
assert.ok(snippet.includes(`data-webklar-termine="${SITE_KEY}"`))
|
||
assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${snippet}`), 'das Snippet steht unveraendert und mit Ueberschrift im Ticket')
|
||
const schritte = absatzMit(r.kurz, 'Schritte:').split('\n').slice(1)
|
||
assert.equal(schritte.length, 6, schritte.join('\n'))
|
||
schritte.forEach((s, i) => assert.ok(s.startsWith(`${i + 1}. `), s))
|
||
const reihenfolge = [/WOMS/, /Einbau-Code/, /Kontrast/, /Datenschutz-Absatz/, /„Freischalten \(Abnahme\)“.*Probebuchung.*stornier/, /Ticket erledigen.*Zeit.*Nicht auf das Stundenkontingent anrechnen/]
|
||
reihenfolge.forEach((muster, i) => assert.match(schritte[i], muster, schritte[i]))
|
||
assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`), 'der Verweis steht zuletzt')
|
||
assert.ok(!r.kurz.includes('Online-Terminbuchung'), 'der Datenschutz-Absatz gehoert nicht ins Ticket')
|
||
assert.ok(!r.kurz.includes(datenschutzAbsatz(e, 'sie').slice(0, 80)))
|
||
for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken), 'kein Kalender-Token')
|
||
assert.ok(!/feedtoken/i.test(r.kurz))
|
||
assert.ok(!r.kurz.includes(e.betrieb.email), 'die Betriebs-E-Mail steht nur in den Datenschutz-Absaetzen')
|
||
})
|
||
|
||
test('D4 kurz Schritt 4 und 6: Datenschutz-Fassung nach Anrede, Preis nur bei bezahlter Einrichtung, die Pflichtformel steht immer', () => {
|
||
const schritt = (zusatz, n) => absatzMit(erzeuge(zusatz).kurz, 'Schritte:').split('\n')[n]
|
||
const du = fixtureEinstellung()
|
||
du.anrede = 'du'
|
||
assert.match(schritt({}, 4), /Sie-Form/)
|
||
assert.match(schritt({ einstellung: du }, 4), /Du-Form/)
|
||
assert.ok(schritt({}, 4).includes(DATENSCHUTZ_ENDREGEL), 'dieselbe Endregel wie in der Checkliste')
|
||
assert.match(schritt({ feature: PAKET_FEATURE }, 6), /Nicht auf das Stundenkontingent anrechnen \(Einrichtung bezahlt, 149 €\)/)
|
||
assert.ok(schritt({}, 6).includes('Nicht auf das Stundenkontingent anrechnen'))
|
||
assert.ok(!schritt({}, 6).includes('bezahlt'), 'ohne Kauf keine Behauptung, die Einrichtung sei bezahlt')
|
||
for (const unlock of [undefined, null, 1, 'true']) assert.ok(!schritt({ feature: { ...PAKET_FEATURE, unlockedByPurchase: unlock } }, 6).includes('bezahlt'), String(unlock))
|
||
})
|
||
|
||
test('D4 kurz Verweis: anleitungsLink des Projekts, ohne IDs der allgemeine Hinweis', () => {
|
||
assert.ok(erzeuge().kurz.endsWith(`Verweis: ${LINK}`))
|
||
assert.equal(LINK, `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=p1`)
|
||
const ohne = erzeuge({ feature: { $id: 'f1', siteKey: SITE_KEY } })
|
||
assert.ok(ohne.kurz.endsWith('Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'), ohne.kurz.slice(-120))
|
||
assert.ok(!ohne.kurz.includes('?tab='))
|
||
})
|
||
|
||
test('D4 kurz Kundenwunsch: Modus, Knopf-Text, Stelle, Gestaltung und Bemerkung als Text des Kunden (eingerueckt), ohne Fragebogen der Standard', () => {
|
||
const mit = erzeuge({ fragebogen: KNOPF_FRAGEBOGEN })
|
||
const block = absatzMit(mit.kurz, 'Kundenwunsch')
|
||
assert.deepEqual(block.split('\n'), [
|
||
'Kundenwunsch (Text des Kunden):',
|
||
' Modus: knopf',
|
||
' Knopf-Text: Termin sichern',
|
||
' Stelle: Startseite unter dem Hero',
|
||
' Gestaltung: Schlicht, wie die Seite',
|
||
' Bemerkung: <script>alert(1)</script>',
|
||
])
|
||
assert.ok(mit.kurz.includes('data-modus="knopf" data-knopf="Termin sichern"'))
|
||
assert.equal(mit.kurz.split('alert(1)').length - 1, 1, 'der Wunsch steht nur im Kundenwunsch-Block, nie im Snippet')
|
||
|
||
const ohne = absatzMit(erzeuge({ fragebogen: null }).kurz, 'Kundenwunsch').split('\n')
|
||
assert.deepEqual(ohne, ['Kundenwunsch (Text des Kunden):', ' Modus: inline (Standard)', ' Sonst keine Angaben im Fragebogen'])
|
||
|
||
// mehrzeilig: weitere Zeilen eingerueckt, Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext), keine Ueberschrift, kein falscher Stand
|
||
const mehrKurz = erzeuge({ fragebogen: { einbau: { modus: 'inline', bemerkung: 'Eins\n\n\nZwei\nKonfigurationsstand: Version 99' } } }).kurz
|
||
assert.deepEqual(absatzMit(mehrKurz, 'Kundenwunsch').split('\n').slice(2), [' Bemerkung: Eins', ' Zwei', ' Konfigurationsstand: Version 99'])
|
||
assert.ok(!/^Konfigurationsstand: Version 99/m.test(mehrKurz), 'der Wunsch kann den Stand nicht faelschen')
|
||
assert.equal(ticketStand(mehrKurz), 3)
|
||
assert.equal(absaetze(mehrKurz).length, absaetze(erzeuge().kurz).length, 'die Absatzzahl aendert sich durch den Wunsch nicht')
|
||
})
|
||
|
||
test('D4 kurz Zaehlungen: Anzahl, aktive und die ersten 8 Namen, danach "und N weitere"', () => {
|
||
const umfang = absatzMit(erzeuge().kurz, 'Umfang:').split('\n')
|
||
assert.deepEqual(umfang, [
|
||
'Umfang:',
|
||
'- 4 Leistungen (3 aktiv): Herrenschnitt, Damenschnitt, Kinderschnitt, Färben',
|
||
'- 3 Personen und Plätze (2 aktiv): Anna, Ben, Stuhl 2',
|
||
'- 2 Zusatzfelder: Haarlänge, Wunschfrisur',
|
||
])
|
||
const e = fixtureEinstellung()
|
||
e.leistungen = Array.from({ length: 12 }, (_, i) => ({ ...e.leistungen[0], id: `s_z${i}`, name: `Leistung ${i + 1}`, aktiv: i !== 3 }))
|
||
const zeile12 = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1]
|
||
assert.equal(zeile12, '- 12 Leistungen (11 aktiv): Leistung 1, Leistung 2, Leistung 3, Leistung 4, Leistung 5, Leistung 6, Leistung 7, Leistung 8, … und 4 weitere')
|
||
e.leistungen = e.leistungen.slice(0, 8)
|
||
assert.ok(!absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').includes('weitere'), 'genau 8: nichts uebrig')
|
||
e.leistungen = e.leistungen.slice(0, 1)
|
||
e.ressourcen = []
|
||
e.gastFelder = { zusatz: [] }
|
||
assert.deepEqual(absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n').slice(1), ['- 1 Leistung (1 aktiv): Leistung 1', '- 0 Personen und Plätze', '- 0 Zusatzfelder'])
|
||
// sehr lange Namen: je Name hoechstens 60 Zeichen, Umbrueche werden zu Leerzeichen
|
||
e.leistungen = [{ ...e.leistungen[0], name: `${'N'.repeat(100)}\nzwei` }]
|
||
const lang = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1]
|
||
assert.ok(lang.endsWith(`${'N'.repeat(59)}…`), lang)
|
||
assert.ok(!lang.includes('zwei') && !lang.includes('N'.repeat(60)))
|
||
})
|
||
|
||
test('D4 kurz Warnungen: hoechstens 5 in der Reihenfolge von warnungen, das Banner nur einmal, ohne Warnung kein Absatz', () => {
|
||
const r = erzeuge({ einstellung: mitSechsWarnungen(), entwurf: true })
|
||
assert.equal(r.warnungen.length, 6, r.warnungen.join('\n'))
|
||
const block = absatzMit(r.kurz, 'Warnungen:').split('\n')
|
||
assert.deepEqual(block, ['Warnungen:', ...r.warnungen.slice(0, 5).map((w) => `- ${w}`)], 'die ersten fuenf, die sechste (Kontrast) bleibt dem Panel')
|
||
assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1, 'der Banner-Text steht genau einmal im Ticket')
|
||
assert.equal(block[1], `- ${ENTWURF_BANNER}`)
|
||
const ohne = erzeuge({ entwurf: false })
|
||
assert.deepEqual(ohne.warnungen, [])
|
||
assert.ok(!ohne.kurz.includes('Warnungen:'))
|
||
assert.equal(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).warnungen.length, 5)
|
||
assert.equal(absatzMit(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).kurz, 'Warnungen:').split('\n').length, 6)
|
||
})
|
||
|
||
test('D4 kurz Gesundheit: eigener Absatz, zaehlt nicht zu den 5 Warnungen, wohlgeformt, einzeilig und als Klartext', () => {
|
||
const e = mitSechsWarnungen()
|
||
e.leistungen[0].name = 'Physiotherapie'
|
||
e.leistungen[1].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
|
||
e.leistungen[1].beschreibung = 'Therapie'
|
||
e.leistungen[2].name = 'Zeile1\n## Zeile2 <b>x</b>'
|
||
e.leistungen[2].beschreibung = 'Physiotherapie'
|
||
const r = erzeuge({ einstellung: e, entwurf: true })
|
||
const gesundheit = absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n')
|
||
const meldungen = gesundheitsMeldungen(e)
|
||
assert.equal(meldungen.length, 3, meldungen.join('\n'))
|
||
assert.deepEqual(gesundheit, [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)])
|
||
assert.equal(absatzMit(r.kurz, 'Warnungen:').split('\n').length, 6, 'die fuenf Warnungen bleiben voll, die Gesundheit kommt dazu')
|
||
for (const m of meldungen) {
|
||
assert.ok(m.isWellFormed() && !/[\r\n]/.test(m), JSON.stringify(m))
|
||
assert.ok(!r.warnungen.every((w) => w !== m), 'dieselben Meldungen stehen auch in warnungen')
|
||
}
|
||
assert.ok(meldungen.some((m) => m.includes('<b>x</b>')), 'Klartext, nichts wird maskiert')
|
||
assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz))
|
||
// keine Treffer: kein Absatz
|
||
assert.ok(!erzeuge().kurz.includes(GESUNDHEIT_KOPF))
|
||
// Reihenfolge der Absaetze
|
||
const stellen = ['Funktion:', 'Kundenwunsch', 'Umfang:', 'Warnungen:', GESUNDHEIT_KOPF, 'Einbau-Code (Snippet):', 'Schritte:', 'Verweis:'].map((a) => absaetze(erzeuge({ einstellung: e, entwurf: true, fragebogen: KNOPF_FRAGEBOGEN }).kurz).findIndex((x) => x.startsWith(a)))
|
||
assert.ok(stellen.every((i) => i >= 0), JSON.stringify(stellen))
|
||
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b))
|
||
})
|
||
|
||
test('D4 gesundheitsMeldungen: ohne Treffer und ohne Einstellung leer, sonst die bereinigten Meldungen der Pruefung aus C', () => {
|
||
assert.deepEqual(gesundheitsMeldungen(null), [])
|
||
assert.deepEqual(gesundheitsMeldungen(undefined), [])
|
||
assert.deepEqual(gesundheitsMeldungen(fixtureEinstellung()), [])
|
||
const e = fixtureEinstellung()
|
||
e.leistungen[0].name = 'Physiotherapie'
|
||
assert.deepEqual(gesundheitsMeldungen(e), ['Leistungsname enthält „Physiotherapie“'])
|
||
const halb = fixtureEinstellung()
|
||
halb.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
|
||
halb.leistungen[0].beschreibung = 'Physiotherapie'
|
||
for (const m of gesundheitsMeldungen(halb)) assert.ok(m.isWellFormed(), JSON.stringify(m))
|
||
})
|
||
|
||
test('D4 kurz ohne Einstellungen: Kopf mit Version 0, Warnung, Snippet, Schritte und Verweis', () => {
|
||
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: true })
|
||
const [kopf] = absaetze(r.kurz)
|
||
assert.ok(kopf.endsWith('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), kopf)
|
||
assert.ok(r.kurz.includes('noch keine Einstellungen'))
|
||
assert.deepEqual(absatzMit(r.kurz, 'Warnungen:').split('\n'), ['Warnungen:', `- ${ENTWURF_BANNER}`, '- Keine Einstellungen gespeichert'])
|
||
assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${r.abschnitte[0].text}`))
|
||
assert.ok(!r.kurz.includes('Umfang:') && !r.kurz.includes(GESUNDHEIT_KOPF))
|
||
assert.equal(absatzMit(r.kurz, 'Schritte:').split('\n').length, 7)
|
||
assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`))
|
||
assert.ok(r.kurz.length <= TICKET_DETAILS_MAX)
|
||
// der Wunsch des Kunden (Fragebogen) steht auch ohne Einstellungen im Ticket
|
||
assert.ok(erzeuge({ einstellung: null, version: 0, fragebogen: KNOPF_FRAGEBOGEN }).kurz.includes(' Knopf-Text: Termin sichern'))
|
||
})
|
||
|
||
test('D4 kurz Maximalbefuellung: hoechstens TICKET_DETAILS_MAX, Kopf, Snippet, Verweis, Stundenkontingent und alle 10 Gesundheitswarnungen bleiben', () => {
|
||
const e = maximalEinstellung()
|
||
const meldungen = gesundheitsMeldungen(e)
|
||
assert.equal(meldungen.length, 10, 'die Pruefung aus C liefert hoechstens 10')
|
||
const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true })
|
||
assert.ok(r.kurz.length <= TICKET_DETAILS_MAX, `kurz ${r.kurz.length}`)
|
||
assert.ok(r.kurz.length > 3000, 'es steht etwas Sinnvolles da, nicht nur ein Rumpf')
|
||
assert.equal(absaetze(r.kurz)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
|
||
assert.ok(r.kurz.includes(abschnitt(r, 'snippet').text))
|
||
assert.ok(r.kurz.includes('Nicht auf das Stundenkontingent anrechnen'))
|
||
assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`))
|
||
assert.deepEqual(absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n'), [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)])
|
||
assert.ok(!r.kurz.includes('Online-Terminbuchung'))
|
||
for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken))
|
||
assert.ok(r.kurz.isWellFormed())
|
||
assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1)
|
||
})
|
||
|
||
test('D4 kurz Platzdruck: erst fallen die Zaehlungen, dann die Warnungen, dann der Kundenwunsch weg; Pflichtteile und Gesundheit nie', () => {
|
||
const gesehen = new Set()
|
||
let zuletzt = null
|
||
// das Snippet waechst mit dem (hier absichtlich nicht normalisierten) Telefon und treibt die Pflichtteile nach oben
|
||
for (let laenge = 1; laenge <= 3200; laenge += 8) {
|
||
const e = maximalEinstellung()
|
||
e.betrieb.telefon = '0'.repeat(laenge)
|
||
e.betrieb.adresse = ''
|
||
const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true })
|
||
const k = r.kurz
|
||
assert.ok(k.length <= TICKET_DETAILS_MAX, `Telefon ${laenge}: kurz ${k.length}`)
|
||
// Pflichtteile
|
||
assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
|
||
assert.ok(k.includes(abschnitt(r, 'snippet').text), `Telefon ${laenge}: Snippet`)
|
||
assert.ok(k.includes('Nicht auf das Stundenkontingent anrechnen') && k.endsWith(`Verweis: ${LINK}`))
|
||
assert.deepEqual(absatzMit(k, GESUNDHEIT_KOPF).split('\n').slice(1), gesundheitsMeldungen(e).map((m) => `- ${m}`), `Telefon ${laenge}: Gesundheit`)
|
||
// Stufen
|
||
const umfang = k.includes('\n\nUmfang:')
|
||
const warnungen = k.includes('\n\nWarnungen:')
|
||
const wunsch = k.includes('\n\nKundenwunsch (Text des Kunden):')
|
||
if (umfang) assert.ok(warnungen && wunsch, `Telefon ${laenge}: Zaehlungen nur mit Warnungen und Wunsch`)
|
||
if (warnungen) assert.ok(wunsch, `Telefon ${laenge}: Warnungen nur mit Wunsch`)
|
||
if (warnungen) {
|
||
const anzahl = absatzMit(k, 'Warnungen:').split('\n').length - 1
|
||
assert.deepEqual(absatzMit(k, 'Warnungen:').split('\n').slice(1), r.warnungen.filter((w) => !gesundheitsMeldungen(e).includes(w)).slice(0, anzahl).map((w) => `- ${w}`), 'es fallen die letzten Warnungen zuerst weg')
|
||
}
|
||
gesehen.add(`${umfang ? 'Umfang' : '-'}/${warnungen ? 'Warnungen' : '-'}/${wunsch ? 'Wunsch' : '-'}`)
|
||
zuletzt = k
|
||
}
|
||
assert.ok(gesehen.has('Umfang/Warnungen/Wunsch'), [...gesehen].join(' '))
|
||
assert.ok(gesehen.has('-/Warnungen/Wunsch'), [...gesehen].join(' '))
|
||
assert.ok(gesehen.has('-/-/Wunsch'), [...gesehen].join(' '))
|
||
assert.ok(gesehen.has('-/-/-'), [...gesehen].join(' '))
|
||
assert.ok(zuletzt.length > 0)
|
||
})
|
||
|
||
test('D4 kurz: selbst ein riesiges Snippet sprengt die Grenze nicht, die Gesundheit steht vor dem Schnitt', () => {
|
||
const e = maximalEinstellung()
|
||
e.betrieb.telefon = '9'.repeat(20000)
|
||
const k = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, entwurf: true }).kurz
|
||
assert.ok(k.length <= TICKET_DETAILS_MAX, `kurz ${k.length}`)
|
||
assert.ok(k.isWellFormed() && !HALBES_PAAR.test(k))
|
||
assert.ok(k.includes(GESUNDHEIT_KOPF))
|
||
assert.ok(k.endsWith('… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'))
|
||
assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
|
||
})
|
||
|
||
test('D4 kurz ist wohlgeformt: halbe Zeichenpaare in Namen, Wuenschen, Projekt und Kunde werden ersetzt, Emojis bleiben', () => {
|
||
const e = fixtureEinstellung()
|
||
e.leistungen[0].name = `Schnitt \uD83D${'x'.repeat(10)}😀`
|
||
e.ressourcen[0].name = `Anna\uDE00`
|
||
const fb = { einbau: { modus: 'inline', bemerkung: `halb \uD83D und ganz 😀`, stelle: '\uDE00' } }
|
||
const r = erzeuge({ einstellung: e, fragebogen: fb, project: { projectName: `Salon \uD83D`, liveDomain: 'x.de' }, kunde: { name: `K\uDE00unde` } })
|
||
assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz), JSON.stringify(r.kurz.match(HALBES_PAAR)))
|
||
assert.ok(r.kurz.includes('😀'), 'ganze Zeichenpaare bleiben')
|
||
assert.ok(JSON.parse(JSON.stringify(r.kurz)) === r.kurz)
|
||
})
|
||
|
||
test('D4 kurz ist deterministisch, unabhaengig von now und aendert die Eingaben nicht', () => {
|
||
const e = maximalEinstellung()
|
||
const fb = structuredClone(MAX_FRAGEBOGEN)
|
||
const vorher = structuredClone({ e, fb })
|
||
const a = erzeuge({ einstellung: e, fragebogen: fb })
|
||
assert.equal(erzeuge({ einstellung: e, fragebogen: fb }).kurz, a.kurz)
|
||
assert.equal(erzeuge({ einstellung: e, fragebogen: fb, now: SPAETER }).kurz, a.kurz, 'in der Kurzfassung steht kein Erzeugungsdatum')
|
||
assert.deepEqual(structuredClone({ e, fb }), vorher)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- ticketStand, driftWarnung
|
||
|
||
test('D4 ticketStand liest Konfigurationsstand: Version N am Zeilenanfang, sonst null', () => {
|
||
assert.equal(ticketStand('Funktion: Terminplaner\nKonfigurationsstand: Version 7, 03.10.2026\n\nrest'), 7)
|
||
assert.equal(ticketStand('Konfigurationsstand: Version 7, 03.10.2026'), 7)
|
||
assert.equal(ticketStand('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), 0)
|
||
assert.equal(ticketStand('x\r\nKonfigurationsstand: Version 12'), 12)
|
||
assert.equal(ticketStand('Konfigurationsstand: Version 999999999'), 999999999, 'neun Stellen sind erlaubt')
|
||
assert.equal(ticketStand('Konfigurationsstand: Version 99999999999'), null, 'elf Stellen: kein Stand')
|
||
assert.equal(ticketStand('Konfigurationsstand: Version 1234567890'), null, 'zehn Stellen: kein Stand')
|
||
for (const ohne of ['', 'Funktion: Terminplaner', 'Konfigurationsstand: Version', 'Konfigurationsstand: Version x', ' Konfigurationsstand: Version 7', 'Der Konfigurationsstand: Version 7', 'konfigurationsstand: Version 7', 'Konfigurationsstand: Version -7']) {
|
||
assert.equal(ticketStand(ohne), null, JSON.stringify(ohne))
|
||
}
|
||
for (const nichtText of [undefined, null, 7, {}, ['Konfigurationsstand: Version 7']]) assert.equal(ticketStand(nichtText), null, String(nichtText))
|
||
})
|
||
|
||
test('D4 driftWarnung: nur wenn die Einstellungen jetzt eine hoehere Version haben als das Ticket', () => {
|
||
assert.equal(driftWarnung(7, 9), 'Einstellungen seit der Bestellung geändert (Ticket Version 7, jetzt 9).')
|
||
assert.equal(driftWarnung(0, 1), 'Einstellungen seit der Bestellung geändert (Ticket Version 0, jetzt 1).', 'Version 0 im Ticket ist ein gueltiger Stand')
|
||
assert.equal(driftWarnung(9, 9), null)
|
||
assert.equal(driftWarnung(9, 7), null)
|
||
assert.equal(driftWarnung(null, 9), null)
|
||
assert.equal(driftWarnung(7, null), null)
|
||
assert.equal(driftWarnung(undefined, 9), null)
|
||
assert.equal(driftWarnung('7', 9), null)
|
||
assert.equal(driftWarnung(7.5, 9), null)
|
||
})
|