Kanzlei-Paket Terminplaner: Aufbau, Texte, Ablauf, Fundstellen

Sieben Abschnitte nach D5: Zweck und Umfang, Produktablauf, Texte in
Endfassung (wörtliche Zitate mit Marker auf die Fragen), Fundstellen im Code,
Nicht Teil der Prüfung (mit Zuordnung der früheren offenen Punkte) und
Checkliste für den Inhaber. Abschnitt 4 (Fragen Q1 bis Q26) folgt.

Der Test prüft Aufbau, jedes Zitat gegen Programm und Dokumente, die
Fundstellen, die Zahlen gegen den Code und die Konsistenz der Abo-Ende-Texte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 05:24:11 +00:00
1 parent ec320853ce
commit 6765b14feb
2 files changed
+1008

No files matched your search

+379
View File
@@ -0,0 +1,379 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { config } from '../server/config.js'
import {
RECHTSTEXTE_VERSION,
GESUNDHEIT_HINWEIS,
ZUSTIMMUNG_ERINNERUNG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_VORZEITIG,
datenschutzAbsatz,
formularHinweis,
widerrufsbelehrung,
widerrufsbelehrungFunktion,
} from '../server/services/termine/rechtstexte.js'
import { knopfText } from '../server/services/termine/einstellungen.js'
import { VORLAGEN } from '../server/services/termine/vorlagen.js'
import { LOESCHFRIST_TAGE } from '../server/services/termine/frist.js'
import { FREIZEIT_HINWEIS, STORNO_MUSTER_HINWEIS, WIDERRUF_HINWEIS } from '../public/termine-tab-einstellungen.js'
// Kanzlei-Paket Terminplaner (Tasks F15a und F15b): docs/rechtliches/termine-kanzlei-paket.md. Das Paket zitiert Texte, die im
// Programm und in den Dokumenten stehen; dieser Test prueft, dass jedes Zitat wortgleich in einer Quelle steht, dass die Zahlen
// zum Code passen und dass die Fundstellen existieren. Aendert sich ein Text (neues Fassungsdatum), muss das Paket nachgezogen werden.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
const norm = (text) => text.replace(/\s+/g, ' ').trim()
const PAKET_PFAD = 'docs/rechtliches/termine-kanzlei-paket.md'
const PAKET = lies(PAKET_PFAD)
const F = config.termine.avvVersion
const V = RECHTSTEXTE_VERSION
// ------------------------------------------------------------------------------------------------ Aufbau des Pakets
const UEBERSCHRIFTEN = [
'## 1. Zweck und Umfang',
'## 2. Produktablauf in Kurzform',
'## 3. Texte in Endfassung',
'## 4. Fragen an die Kanzlei',
'## 5. Fundstellen im Code',
'## 6. Nicht Teil der Kanzleiprüfung',
'## 7. Checkliste für den Inhaber',
]
/** Text eines Abschnitts (## n. …) ohne die Überschrift; bis zur nächsten ##-Überschrift. */
function abschnitt(nr) {
const zeilen = PAKET.split('\n')
const start = zeilen.findIndex((z) => z.startsWith(`## ${nr}. `))
assert.ok(start >= 0, `Abschnitt ${nr} fehlt`)
let ende = zeilen.findIndex((z, i) => i > start && /^## /.test(z))
if (ende < 0) ende = zeilen.length
return zeilen.slice(start + 1, ende).join('\n')
}
/** Zitatblöcke eines Textes: zusammenhängende Zeilen mit „>“; Absätze durch eine Zeile „>“ getrennt. Das maskierte Sternchen „\*“ gilt als „*“. */
function zitatBloecke(text) {
const zeilen = text.split('\n')
const bloecke = []
let aktuell = null
zeilen.forEach((zeile, i) => {
if (/^>/.test(zeile)) {
if (!aktuell) {
aktuell = { zeilen: [], start: i, ende: i }
bloecke.push(aktuell)
}
aktuell.zeilen.push(zeile.replace(/^> ?/, ''))
aktuell.ende = i
} else {
aktuell = null
}
})
return bloecke.map((b) => {
const absaetze = []
let teil = []
for (const z of b.zeilen) {
if (z.trim() === '') {
if (teil.length) absaetze.push(teil)
teil = []
} else {
teil.push(z)
}
}
if (teil.length) absaetze.push(teil)
return {
start: b.start,
ende: b.ende,
absaetze: absaetze.map((a) => norm(a.join(' ').replace(/\\\*/g, '*'))),
}
})
}
/** Alle Fragenummern hinter einem ⚖ (Marker „⚖ Q3, Q5“ oder Zellen einer Tabelle). */
function marker(text) {
const nummern = []
for (const m of text.matchAll(/⚖((?:\s*(?:Q\d+)(?:\s*[,·]\s*|\s+und\s+)?)+)/g)) {
for (const q of m[1].matchAll(/Q(\d+)/g)) nummern.push(Number(q[1]))
}
return nummern
}
test('paket_aufbau: sieben Überschriften, beide Fassungen und alle Teile von Abschnitt 3', () => {
const ueberschriften = PAKET.split('\n').filter((z) => /^## /.test(z))
assert.deepEqual(ueberschriften, UEBERSCHRIFTEN)
assert.equal(PAKET.split('\n').filter((z) => /^# /.test(z)).length, 1, 'genau eine Titelzeile')
assert.match(F, /^\d{4}-\d{2}-\d{2}$/)
assert.match(V, /^\d{4}-\d{2}-\d{2}$/)
const kopf = PAKET.split('\n## 1.')[0]
assert.ok(kopf.includes('`config.termine.avvVersion`') && kopf.includes(`**${F}**`), 'Fassung der Vertragsunterlagen im Kopf')
assert.ok(kopf.includes('`RECHTSTEXTE_VERSION`') && kopf.includes(`**${V}**`), 'Fassung der Gästetexte im Kopf')
// Abschnitt 1: Kernpaket, Hausrunde, keine Rechtsberatung, Fassungen
const eins = abschnitt(1)
for (const wort of ['Kernpaket', 'Hausrunde', 'Keine Rechtsberatung durch WEBklar', 'Fassungen']) assert.ok(eins.includes(wort), wort)
// Abschnitt 2: die Schritte des Ablaufs in der vorgegebenen Reihenfolge
const zwei = abschnitt(2)
const reihenfolge = ['| 1. Abo-Prüfung |', '| 2. Fragebogen-Entwurf |', '| 3. Bestellung mit vier Haken |', '| 4. Nachweis |', '| 5. Zahlung |', '| 6. Ticket und Bestätigung |', '| 7. Einbau und Freischaltung |', '| 8. Betrieb |']
let pos = -1
for (const schritt of reihenfolge) {
const i = zwei.indexOf(schritt)
assert.ok(i > pos, `Schritt „${schritt}“ fehlt oder steht an der falschen Stelle`)
pos = i
}
for (const wort of ['30 Minuten', 'Bestätigungslink', 'Vertrag widerrufen', 'spätestens am folgenden Tag', '30 Tage', 'Rückkehr', 'vollständig']) assert.ok(zwei.includes(wort), wort)
// Abschnitt 3: die Unterabschnitte 3.1 bis 3.11
const drei = abschnitt(3)
const teile = [...drei.matchAll(/^### (3\.\d+) /gm)].map((m) => m[1])
assert.deepEqual(teile, Array.from({ length: 11 }, (_, i) => `3.${i + 1}`))
// Abschnitt 4 existiert (die Fragen selbst prüft paket_marker_und_fragen)
assert.ok(abschnitt(4).trim().length > 0)
assert.ok(abschnitt(7).includes('- [ ]'), 'Checkliste als Aufgabenliste')
})
// ------------------------------------------------------------------------------------------------ Zitate
/** Text einer Mailvorlage ohne Markup (nur für den Wortlautvergleich); Inline-Auszeichnungen fallen ohne Leerzeichen weg. */
function mailText(rel) {
return norm(
lies(rel)
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<style[\s\S]*?<\/style>/g, ' ')
.replace(/<\/?(?:strong|b|em|i|u|span|a)(?:\s[^>]*)?>/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&'),
)
}
const PLATZHALTER_BETRIEB = { name: '{{Name des Betriebs}}', adresse: '{{Anschrift}}', telefon: '{{Telefon}}', email: '{{E-Mail}}' }
const PLATZHALTER_EINSTELLUNG = (anrede) => ({
anrede,
aufbewahrungTage: 90,
betrieb: { name: '{{Firma}}', adresse: '{{Anschrift}}', email: '{{E-Mail}}', telefon: '{{Telefon}}' },
gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] },
erinnerung: { anbieten: false },
})
/** Alle Quellen, in denen ein Zitat stehen darf (je Quelle whitespace-normalisiert). */
function quellen() {
const t = config.features.termine
const n = config.features.news
const roh = [
// Register: Haken und Knöpfe der Bestellung, Umsatzsteuerhinweis
...t.consent.items.map((i) => i.text),
t.consent.knopf,
...n.consent.items.map((i) => i.text),
n.consent.knopf,
config.ustHinweis,
// Ausgaben von rechtstexte.js
widerrufsbelehrungFunktion(),
widerrufsbelehrung(PLATZHALTER_BETRIEB),
formularHinweis(PLATZHALTER_EINSTELLUNG('sie')),
formularHinweis(PLATZHALTER_EINSTELLUNG('du')),
datenschutzAbsatz(PLATZHALTER_EINSTELLUNG('sie'), 'sie'),
datenschutzAbsatz(PLATZHALTER_EINSTELLUNG('du'), 'du'),
ZUSTIMMUNG_VORZEITIG('{{Firma}}'),
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_ERINNERUNG('{{n}}'),
GESUNDHEIT_HINWEIS,
knopfText({ buchungsart: 'anfrage' }),
knopfText({ buchungsart: 'anfrage' }, { bestaetigen: true }),
knopfText({ preisArt: 'fest', preisCent: 5000 }),
knopfText({ preisArt: 'fest', preisCent: 5000 }, { bestaetigen: true }),
knopfText({ preisArt: 'keiner' }),
knopfText({ preisArt: 'keiner' }, { bestaetigen: true }),
// die drei Markdown-Dokumente der Vertragsunterlagen
lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md'),
lies('docs/rechtliches/avv-terminplaner.md'),
lies('docs/rechtliches/widerrufsbelehrung-funktionen.md'),
// Hinweise-Dokument der Gäste (Knopf „Vertrag widerrufen“ und „Widerruf bestätigen“ der Widerrufsfunktion)
lies('docs/rechtliches/termine-hinweise.md'),
// Mailvorlage G02 (Hinweise zum Widerrufsrecht in der Bestätigung)
mailText('server/mail-templates/G02 Termin bestaetigt.html'),
// früherer Text des News-Hakens (Korrekturvermerk in der Spezifikation)
lies('docs/superpowers/specs/2026-09-30-funktionen-news-design.md'),
// Hinweise im Kundenportal und Storno-Vorschläge der Vorlagen
WIDERRUF_HINWEIS,
FREIZEIT_HINWEIS,
STORNO_MUSTER_HINWEIS,
...Object.values(VORLAGEN).map((v) => v.storno),
]
return roh.map(norm)
}
test('paket_zitate_woertlich: jeder Zitatabsatz steht wortgleich in einer Quelle', () => {
const alle = quellen()
const bloecke = zitatBloecke(PAKET)
assert.ok(bloecke.length >= 30, `erwartet viele Zitatblöcke, gefunden ${bloecke.length}`)
const fehlend = []
for (const b of bloecke) {
for (const absatz of b.absaetze) {
assert.ok(absatz.length > 0)
if (!alle.some((q) => q.includes(absatz))) fehlend.push(absatz.slice(0, 120))
}
}
assert.deepEqual(fehlend, [], 'Zitate ohne Quelle (Programm oder Dokument geändert, Paket nachziehen)')
})
test('paket_zitate_woertlich: Bestelltexte und Belehrung der Funktionen vollständig und in der Reihenfolge des Programms', () => {
const t = config.features.termine
const drei = abschnitt(3)
const teil = (nr) => {
const m = new RegExp(`^### ${nr.replace('.', '\\.')} [^\\n]*\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, 'm').exec(drei)
assert.ok(m, `Unterabschnitt ${nr}`)
return m[1]
}
// 3.1: die vier Haken und der Knopf, genau in dieser Reihenfolge
const absaetze31 = zitatBloecke(teil('3.1')).flatMap((b) => b.absaetze)
assert.deepEqual(absaetze31, [...t.consent.items.map((i) => norm(i.text)), norm(t.consent.knopf)])
assert.deepEqual(t.consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
// 3.2: der erste Block ist die Belehrung samt Muster-Widerrufsformular in voller Länge
const erster = zitatBloecke(teil('3.2'))[0]
assert.equal(erster.absaetze.join(' '), norm(widerrufsbelehrungFunktion()))
})
test('paket_zitate_woertlich: hinter jedem Zitat in Abschnitt 3 steht ein Marker ⚖ Q…', () => {
const zeilen = PAKET.split('\n')
const dreiStart = zeilen.findIndex((z) => z.startsWith('## 3. '))
const dreiEnde = zeilen.findIndex((z, i) => i > dreiStart && /^## /.test(z))
const bloecke = zitatBloecke(abschnitt(3))
assert.ok(bloecke.length >= 30)
const ohne = []
for (const b of bloecke) {
const abschnittZeilen = abschnitt(3).split('\n')
let i = b.ende + 1
while (i < abschnittZeilen.length && abschnittZeilen[i].trim() === '') i++
const folge = abschnittZeilen[i] || ''
if (!/^⚖ Q\d+/.test(folge)) ohne.push(abschnittZeilen[b.start].slice(0, 80))
}
assert.deepEqual(ohne, [])
assert.ok(dreiEnde > dreiStart)
// alle Marker nennen nur Fragen von 1 bis 26
for (const q of marker(PAKET)) assert.ok(q >= 1 && q <= 26, `Q${q} außerhalb von Q1 bis Q26`)
})
// ------------------------------------------------------------------------------------------------ Fundstellen
/** Zeilen der Tabelle in Abschnitt 5: { thema, datei, bezeichner[] }. */
function fundstellen() {
const zeilen = abschnitt(5).split('\n').filter((z) => z.startsWith('|'))
assert.match(zeilen[0], /^\| Thema \| Datei \| Funktion\/Konstante \|$/)
return zeilen.slice(2).map((z) => {
const zellen = z.replace(/^\|\s*|\s*\|$/g, '').split(' | ')
assert.equal(zellen.length, 3, z)
const datei = /^`([^`]+)`$/.exec(zellen[1])
assert.ok(datei, `Datei in Backticks: ${z}`)
const bezeichner = [...zellen[2].matchAll(/`([^`]+)`/g)].map((m) => m[1])
assert.ok(bezeichner.length > 0, `Bezeichner in Backticks: ${z}`)
return { thema: zellen[0], datei: datei[1], bezeichner }
})
}
test('paket_fundstellen: jede Datei der Tabelle existiert und enthält den genannten Bezeichner', () => {
const zeilen = fundstellen()
assert.ok(zeilen.length >= 30, `erwartet viele Fundstellen, gefunden ${zeilen.length}`)
const themen = new Set()
for (const { thema, datei, bezeichner } of zeilen) {
assert.ok(!themen.has(thema), `Thema doppelt: ${thema}`)
themen.add(thema)
assert.ok(!/^\/|\.\./.test(datei), `nur Pfade im Repository: ${datei}`)
assert.ok(existsSync(new URL(`../${datei}`, import.meta.url)), `Datei fehlt: ${datei}`)
const inhalt = lies(datei)
for (const id of bezeichner) assert.ok(inhalt.includes(id), `${datei}: „${id}“ steht nicht in der Datei`)
}
// keine Zeilennummern in der Tabelle
assert.ok(!/\.(js|mjs|md|html|sh|txt):\d+/.test(abschnitt(5)), 'Fundstellen ohne Zeilennummern')
// die Anlagen A und B (PDF) und die Quellen der Dokumente existieren
for (const datei of [
`public/rechtliches/avv-terminplaner-${F}.pdf`,
`public/rechtliches/leistungsbeschreibung-terminplaner-${F}.pdf`,
`public/rechtliches/widerrufsbelehrung-funktionen-${F}.pdf`,
'docs/rechtliches/avv-terminplaner.md',
'docs/rechtliches/leistungsbeschreibung-terminplaner.md',
'docs/rechtliches/widerrufsbelehrung-funktionen.md',
]) {
assert.ok(existsSync(new URL(`../${datei}`, import.meta.url)), `Anlage fehlt: ${datei}`)
assert.ok(PAKET.includes(`\`${datei}\``), `Paket nennt ${datei}`)
}
})
// ------------------------------------------------------------------------------------------------ Zahlen
/** `const NAME = n * STUNDE_MS | TAG_MS` oder `const NAME = n` aus einem Quelltext: { n, einheit }. */
function konstante(quelle, name) {
const m = new RegExp(`const ${name} = (\\d+)(?: \\* (STUNDE|TAG)_MS)?`).exec(quelle)
assert.ok(m, `${name} fehlt im Quelltext`)
return { n: Number(m[1]), einheit: m[2] === 'STUNDE' ? 'Stunden' : m[2] === 'TAG' ? 'Tage' : '' }
}
test('paket_zahlen_gleich_code: die Zahlen in Abschnitt 2.5 sind die des Codes', () => {
const tabelle = abschnitt(2).split('### 2.5 Zahlen')[1]
assert.ok(tabelle, 'Tabelle „2.5 Zahlen“')
const zeilen = tabelle.split('\n').filter((z) => z.startsWith('|')).slice(2)
const wert = Object.fromEntries(zeilen.map((z) => [z.split(' | ')[0].replace(/^\|\s*/, ''), z]))
assert.deepEqual(Object.keys(wert), ['149 €', '14 Tage', '30 Minuten', '48 Stunden', '90 Tage', '7 Tage', '30 Tage'])
const JOBS = lies('server/services/termine/jobs.js')
const OUTBOX = lies('server/services/termine/outbox.js')
const AVV = norm(lies('docs/rechtliches/avv-terminplaner.md'))
// 149 €: Preis des Terminplaners im Register und im Produktkatalog (14 900 Cent)
assert.equal(config.features.termine.priceLabel, '149 €')
assert.equal(config.termineSetup.amountCents, 14900)
assert.ok(wert['149 €'].includes('ohne Umsatzsteuer gemäß § 19 UStG') && wert['149 €'].includes('14900'))
assert.ok(wert['149 €'].includes(config.ustHinweis))
// 14 Tage: Widerrufsfrist
assert.equal(konstante(lies('server/services/deadlines.js'), 'WITHDRAWAL_DAYS').n, 14)
assert.ok(norm(widerrufsbelehrungFunktion()).includes('binnen vierzehn Tagen'))
// 30 Minuten: Haltezeit
assert.equal(config.termine.holdMinutes, 30)
assert.ok(AVV.includes('Bestätigungsfrist von 30 Minuten'))
// 48 Stunden: Löschung nicht bestätigter Buchungen
assert.deepEqual(konstante(JOBS, 'VERFALLEN_BEHALTEN_MS'), { n: 48, einheit: 'Stunden' })
// 90 Tage: Aufbewahrung der Termine (Vorgabe) und der Entwürfe
assert.equal(konstante(JOBS, 'AUFBEWAHRUNG_VORGABE').n, 90)
assert.equal(konstante(lies('server/services/termine/entwuerfe.js'), 'ENTWURF_TAGE').n, 90)
assert.ok(AVV.includes('die Vorgabe sind 90 Tage') && AVV.includes('löscht WEBklar 90 Tage nach der letzten Änderung'))
// 7 Tage: Versandwarteschlange nach dem Versand; Sicherungen
assert.deepEqual(konstante(OUTBOX, 'GESENDET_BEHALTEN_MS'), { n: 7, einheit: 'Tage' })
assert.ok(AVV.includes('bis zu sieben Tage erhalten'))
assert.ok(lies('public/abo-bedingungen.html').includes('die letzten sieben Tagessicherungen'))
// 30 Tage: Löschung nach dem Abschalten und abgesagter Termine
assert.equal(LOESCHFRIST_TAGE, 30)
assert.deepEqual(konstante(JOBS, 'ABGESAGT_BEHALTEN_MS'), { n: 30, einheit: 'Tage' })
assert.ok(JOBS.includes('LOESCHFRIST_TAGE * TAG_MS'))
})
// ------------------------------------------------------------------------------------------------ Ende des Abos
test('paket_konsistenz_abo_ende: LB Nr. 8, AVV Nr. 9, 6c und K22 nennen 30 Tage und die Rückkehr', () => {
const abschnittVon = (md, prefix) => {
const zeilen = md.split('\n')
const start = zeilen.findIndex((z) => z.startsWith(prefix))
assert.ok(start >= 0, prefix)
const ende = zeilen.findIndex((z, i) => i > start && /^## /.test(z))
return norm(zeilen.slice(start, ende < 0 ? undefined : ende).join('\n'))
}
const lb8 = abschnittVon(lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md'), '## 8. ')
const avv9 = abschnittVon(lies('docs/rechtliches/avv-terminplaner.md'), '## 9. ')
const sechsC = norm(lies('docs/rechtliches/termine-datenschutz-6c.md'))
const k22 = mailText('server/mail-templates/K22 Terminplaner beendet.html')
const quellenAbo = { 'LB Nr. 8': lb8, 'AVV Nr. 9': avv9, '6c': sechsC, K22: k22 }
for (const [name, text] of Object.entries(quellenAbo)) {
assert.ok(/\b30 Tage\b/.test(text), `${name} nennt „30 Tage“`)
assert.ok(/wieder ein\b|Rückkehr/.test(text), `${name} nennt die Rückkehr`)
}
// die Rückkehr gilt in allen vier Texten für ein neues Wartungs-Abo
for (const [name, text] of Object.entries(quellenAbo)) assert.ok(text.includes('Wartungs-Abo'), `${name} nennt das Wartungs-Abo`)
// dieselbe Zahl wie im Code (frist.js) und im Abgleich
assert.equal(LOESCHFRIST_TAGE, 30)
assert.ok(lb8.includes('30 Tage nach dem Abschalten') && avv9.includes('30 Tage danach'))
// das Paket sagt dasselbe (Abschnitt 2.3)
const drei = norm(abschnitt(2).split('### 2.3 ')[1].split('### 2.4 ')[0])
assert.ok(drei.includes('30 Tage') && drei.includes('Rückkehr') && drei.includes('spätestens am folgenden Tag'))
})