einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt, kunde,
entwurf }) setzt die ersten sechs Abschnitte der Team-Anleitung zusammen (modell, leistungen,
ressourcen, regeln, gastfelder, benachrichtigungen, feste Reihenfolge, nicht kopierbar) plus
stand { version, updatedAt } und den Volltext. Rein: kein I/O, config nur beim Aufruf
(Portal-Adresse, Haltezeit); gleiche Eingabe und gleiches now ergeben gleiche Ausgabe, die
Eingabe bleibt unveraendert.
Feldweissliste statt Objektkopie: nie feedToken, keine Betriebs-E-Mail, keine
Leistungsbeschreibung, keine unbekannten Zusatzfelder. Kundentexte stehen unmaskiert als Text,
einzeilige Werte werden auf eine Zeile gebracht (keine vorgetaeuschte Ueberschrift).
Abwesenheiten und Sonderoeffnungen nur ab heute (Berliner Kalendertag), Listen ueber 20
Eintraege enden mit "und N weitere". Hilfen wochenplanText und kuerzeListe sind exportiert.
Warnungen und Kurzfassung bleiben leer (kommen mit D3b und D4); ohne Einstellung entstehen
keine Abschnitte.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
687 lines
36 KiB
JavaScript
687 lines
36 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 {
|
||
cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
|
||
} from '../server/services/termine/einbauanleitung.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'
|
||
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 sechs Abschnitte in fester Reihenfolge, keiner kopierbar', () => {
|
||
const r = erzeuge()
|
||
assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen'])
|
||
assert.deepEqual(r.abschnitte.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.equal(a.kopierbar, false, a.id)
|
||
assert.ok(a.titel && typeof a.titel === 'string', a.id)
|
||
assert.ok(a.text && typeof a.text === 'string', a.id)
|
||
}
|
||
assert.deepEqual(r.warnungen, [], 'Warnungen kommen mit D3b')
|
||
assert.equal(r.kurz, '', 'die Kurzfassung kommt mit D4')
|
||
// der Volltext fuehrt jeden Abschnitt mit Ueberschrift
|
||
assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), r.abschnitte.map((a) => `## ${a.titel}`))
|
||
for (const a of r.abschnitte) 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', BETRIEB.email]) assert.ok(!json.includes(verboten), verboten)
|
||
})
|
||
|
||
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, 6, r.text)
|
||
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 und liefert keine Abschnitte; ein ungueltiges now wirft', () => {
|
||
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null })
|
||
assert.deepEqual(r.abschnitte, [])
|
||
assert.equal(r.text, '')
|
||
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)
|
||
})
|