Files
Webklar-Kundenbereich/test/termineKanzleiPaket.test.js
T
ClaudeandClaude Sonnet 5.5 7050b63a37 Kanzlei-Paket Terminplaner: Fragen Q1 bis Q26
Abschnitt 4 mit den 26 Fragen (Frage, Fundstelle im Text, unsere Annahme,
Alternative). Jede Kennung der früheren Listen "Für die Rechtsprüfung offen"
(H1 bis C8) steht in der Fundstelle genau einer Frage oder in Abschnitt 6
mit Begründung; die Hinweise aus den Reviews (Pilot/Kulanz, Rückkehr nur
nach Abo-Ende, Erstattung bei Ablehnung, Löschung auf Weisung, vereinbarte
Fassung in K15, G02 bei kostenlosen Buchungen) sind in den passenden Fragen.

Tests: Fragen lückenlos Q1 bis Q26 mit vier Teilen, jede Frage verwiesen,
Q4 nennt News, § 356 Abs. 3 und Altkunden, Abdeckung der entfernten Listen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:31:57 +00:00

499 lines
24 KiB
JavaScript

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(/<script[\s\S]*?<\/script>/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'),
// Datenschutzerklärung von WEBklar (Absatz zur Funktionsbestellung in § 7, zitiert in Q23)
mailText('public/datenschutz.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'))
})
// ------------------------------------------------------------------------------------------------ Fragen Q1 bis Q26 (Task F15b)
const FRAGE_TEILE = ['**Frage.**', '**Fundstelle im Text.**', '**Unsere Annahme.**', '**Alternative.**']
/** Fragen aus Abschnitt 4: [{ nr, titel, text, teile: { frage, fundstelle, annahme, alternative } }] in der Reihenfolge des Pakets. */
function fragen() {
const text = abschnitt(4)
const treffer = [...text.matchAll(/^### Q(\d+): (.+)$/gm)]
return treffer.map((m, i) => {
const ende = i + 1 < treffer.length ? treffer[i + 1].index : text.length
const rumpf = text.slice(m.index, ende)
const stellen = FRAGE_TEILE.map((marke) => rumpf.indexOf(marke))
const teile = {}
;['frage', 'fundstelle', 'annahme', 'alternative'].forEach((name, k) => {
if (stellen[k] < 0) return
const bis = k + 1 < stellen.length ? stellen[k + 1] : rumpf.length
teile[name] = rumpf.slice(stellen[k] + FRAGE_TEILE[k].length, bis).trim()
})
return { nr: Number(m[1]), titel: m[2], text: rumpf, teile, stellen }
})
}
test('paket_marker_und_fragen: Q1 bis Q26 lückenlos, je vier Teile, jede Frage verwiesen, jeder Marker hat seine Frage', () => {
const alle = fragen()
assert.deepEqual(alle.map((f) => f.nr), Array.from({ length: 26 }, (_, i) => i + 1), 'lückenlos Q1 bis Q26 in dieser Reihenfolge')
assert.ok(!abschnitt(4).includes('Task F15b'), 'der Platzhalter von F15a ist ersetzt')
for (const f of alle) {
assert.ok(f.titel.trim().length >= 5, `Q${f.nr}: Titel`)
assert.ok(f.stellen.every((p) => p >= 0) && f.stellen.every((p, k) => k === 0 || p > f.stellen[k - 1]), `Q${f.nr}: vier Teile in der Reihenfolge Frage, Fundstelle, Annahme, Alternative`)
for (const [name, inhalt] of Object.entries(f.teile)) assert.ok(inhalt.length >= 20, `Q${f.nr}: ${name} ist leer`)
assert.ok(!/Task F15|TODO|\?\?\?/.test(f.text), `Q${f.nr}: kein Platzhalter`)
}
// jeder Marker nennt eine vorhandene Frage, und jede Frage wird mindestens einmal verwiesen
const verwiesen = new Set(marker(PAKET))
for (const q of verwiesen) assert.ok(q >= 1 && q <= 26 && alle.some((f) => f.nr === q), `⚖ Q${q} ohne ### Q${q}`)
for (const f of alle) assert.ok(verwiesen.has(f.nr), `Q${f.nr} wird nirgends mit ⚖ verwiesen`)
// Q4 (D7): News-Altkunden
const q4 = alle[3]
for (const wort of ['News', '§ 356 Abs. 3', 'Altkunden']) assert.ok(q4.text.includes(wort), `Q4 nennt „${wort}“`)
assert.ok(q4.teile.frage.includes('[Anzahl'), 'Q4: die Zahl der Fälle liefert der Inhaber')
})
/** Zeilen der Zuordnungstabelle in Abschnitt 6.3: Kennung -> { punkt, wo }. */
function zuordnung() {
const text = abschnitt(6).split('### 6.3 ')[1]
assert.ok(text, '6.3')
const zeilen = text.split('\n').filter((z) => /^\| [HABC]\d /.test(z))
const tabelle = {}
for (const z of zeilen) {
const zellen = z.replace(/^\|\s*|\s*\|$/g, '').split(' | ')
assert.equal(zellen.length, 3, z)
assert.ok(!(zellen[0] in tabelle), `Kennung doppelt: ${zellen[0]}`)
tabelle[zellen[0]] = { punkt: zellen[1], wo: zellen[2] }
}
return tabelle
}
test('paket_offene_punkte_abgedeckt: jeder Punkt der entfernten Listen steht in einer Frage oder hat in Abschnitt 6 einen Grund', () => {
const erwartet = [
...[1, 2, 3, 4].map((n) => `H${n}`),
...[1, 2, 3, 4, 5, 6].map((n) => `A${n}`),
...[1, 2, 3, 4, 5, 6].map((n) => `B${n}`),
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `C${n}`),
]
const tabelle = zuordnung()
assert.deepEqual(Object.keys(tabelle), erwartet, 'jede Kennung genau einmal, in dieser Reihenfolge')
const alle = fragen()
for (const kennung of erwartet) {
const { punkt, wo } = tabelle[kennung]
assert.ok(punkt.length >= 10, `${kennung}: Punkt benannt`)
const frage = /^Q(\d+)$/.exec(wo)
if (frage) {
// Zuordnung zu einer Frage: die Kennung steht in der „Fundstelle im Text“ genau dieser Frage
const f = alle.find((x) => x.nr === Number(frage[1]))
assert.ok(f, `${kennung}: Frage ${wo} fehlt`)
assert.ok(new RegExp(`\\b${kennung}\\b`).test(f.teile.fundstelle), `${kennung} steht nicht in der Fundstelle von ${wo}`)
} else {
// sonst nur mit Begründung (hier) und nur die drei dokumentierten Fälle
assert.ok(wo.startsWith('hier: ') && wo.length > 40, `${kennung}: Grund fehlt`)
assert.ok(['B3', 'C5', 'C6'].includes(kennung), `${kennung} muss in einer Frage stehen`)
for (const f of alle) assert.ok(!new RegExp(`\\b${kennung}\\b`).test(f.teile.fundstelle), `${kennung} steht schon in Q${f.nr}`)
}
}
// jede Kennung in den Fragen ist in der Tabelle bekannt
for (const f of alle) {
for (const m of f.teile.fundstelle.matchAll(/\b([HABC]\d)\b/g)) assert.ok(erwartet.includes(m[1]), `Q${f.nr}: unbekannte Kennung ${m[1]}`)
}
})
test('paket_offene_punkte_abgedeckt: die Hinweise der Reviews (Kanzlei-Quellen) stehen in der passenden Frage', () => {
const alle = fragen()
const frage = (n) => alle[n - 1].text
const hat = (n, ...woerter) => { for (const w of woerter) assert.ok(frage(n).includes(w), `Q${n} nennt „${w}“`) }
hat(1, 'Verbraucher', 'Unternehmer')
hat(2, 'Zahlungseingang', 'K15')
hat(3, '§ 356 Abs. 5 Nr. 2', '§ 357a Abs. 2', 'dauerhaften Datenträger')
hat(5, 'Gesamtpreis', 'Arbeitsblatt', 'Stundenaufwand')
hat(6, 'abo_ende_vor_einrichtung', 'abo_ende', 'Nr. 8 Abs. 4', '§ 307')
hat(7, 'unlockedByPurchase', 'ausgenommen', 'Pilot', 'Kulanz')
hat(8, '§ 309 Nr. 7', 'Höchstbetrag', 'Einstellung')
hat(9, 'disabledAt', 'Terminanfragen', 'Rückkehr', 'Sicherung')
hat(10, 'unverzüglich', 'Absagegrund', 'Zählungen', 'Drittländer', 'Kalender')
hat(11, 'Erstattung', 'ablehnt', 'Art. 9')
hat(12, 'Lieferkosten', 'Gesetzlichkeitsfiktion')
hat(14, '§ 312j Abs. 3')
hat(16, 'G02', '§ 307', 'kostenlosen')
hat(19, '§ 19 UStG', '· 149 €')
hat(20, 'vereinbarten Fassung', 'Art. 246a § 1')
hat(21, 'Textform', 'K16', 'Art. 28 Abs. 9')
hat(22, 'BFSG')
hat(23, 'drei Jahre', 'Browserkennung')
hat(24, '90 Tage')
hat(25, 'Mindestlaufzeit')
hat(26, 'Hash', 'Archiv', 'Haltezeit')
})