Files
lernbar/scripts/pruefungen/abnahme.mjs
T
Florian WildeandClaude Opus 5 c61d5711e0 Baustein 10b: Anleitung mit Screenshots, Abnahmelauf, Fassung 1.0.0
docs/ANLEITUNG.md: 20 Abschnitte, 27 Bildschirmfotos, getrennt fuer
Ausbilder und Azubi, dazu "Wenn etwas nicht geht". Die Bilder entstehen
automatisiert ueber npm run screenshots - was dort steht, ist gemessen
und nicht gemalt.

scripts/pruefungen/abnahme.mjs (npm run abnahme) laeuft gegen eine
frische, leere Datenbank in einem Wegwerfverzeichnis: aufsetzen, Konten
anlegen, arbeiten, abnehmen, sichern, beschaedigen, zurueckspielen. Eine
Sicherung, die nie zurueckgespielt wurde, ist keine Sicherung. 33 von 33
Pruefungen erfuellt, Protokoll in docs/ABNAHME.md.

Dabei drei echte Fehler gefunden, alle in Wegen, die nie ausgefuehrt
worden waren:

- Die Sicherungsskripte liefen nicht in Git Bash, obwohl das in ihrem
  Kopf stand: tar deutet den Doppelpunkt in "C:\..." als Rechnernamen.
  Beide wechseln jetzt erst ins Verzeichnis und uebergeben einen blossen
  Dateinamen.
- Auf einer frischen Datenbank gibt es noch kein Wochenblatt; der Weg
  jedes neuen Azubis war nie durchlaufen worden.
- Der Abnahmelauf selbst liess unter Windows seinen Server ueberleben
  (kill() beendet nur die cmd.exe) und prueft deshalb jetzt vorher, ob
  der Port frei ist - sonst haette er gegen fremde Daten geprueft.

Fassung 1.0.0: package.json stand noch auf 0.1.0, waehrend das
Aenderungsprotokoll bei 0.9.0 war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:26:34 +02:00

497 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chromium } from '@playwright/test';
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Der Abnahmelauf.
*
* Anders als die Prüfungen der einzelnen Bausteine läuft dieser hier gegen
* eine **frische, leere Datenbank** in einem Wegwerfverzeichnis. Er beweist
* damit nicht nur, dass die Anwendung funktioniert, sondern dass sie sich aus
* dem Nichts aufsetzen lässt: Migrationen laufen an, das erste Ausbilderkonto
* entsteht auf der Kommandozeile, alles Weitere über die Oberfläche.
*
* Am Ende wird gesichert, absichtlich etwas kaputtgemacht und zurückgespielt.
* Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.
*
* node scripts/pruefungen/abnahme.mjs
*
* Voraussetzung: `npm run build` ist gelaufen.
*/
const PORT = Number(process.env.LERNBAR_ABNAHME_PORT ?? 3399);
const BASIS = `http://localhost:${PORT}`;
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'abnahme-ausbilder' };
const AZUBI = { name: 'Tim Vogt', email: 'tim.vogt@betrieb.test', pw: 'abnahme-azubi-2026' };
let fehler = 0;
const protokoll = [];
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
protokoll.push({ erfuellt: Boolean(bedingung), text, zusatz });
if (!bedingung) fehler += 1;
}
function abschnitt(titel) {
console.log(`\n${titel}`);
protokoll.push({ abschnitt: titel });
}
// --- Wegwerfumgebung -------------------------------------------------------
const daten = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-abnahme-'));
const umgebung = { ...process.env, LERNBAR_DATEN: daten, PORT: String(PORT), TZ: 'Europe/Berlin' };
console.log(`Wegwerfverzeichnis: ${daten}`);
let server = null;
let browser = null;
/**
* Den Server beenden – wirklich.
*
* Unter Windows beendet `kill()` auf einem über die Eingabeaufforderung
* gestarteten Prozess nur diese, nicht den Node-Prozess darunter. Deshalb wird
* hier ohne Eingabeaufforderung gestartet und zur Sicherheit der ganze Baum
* abgeräumt. Ein überlebender Server aus einem früheren Lauf ist kein
* Schönheitsfehler: Er belegt den Port, der nächste Lauf bekommt ihn nicht,
* und dann prüft man stundenlang gegen den alten Datenbestand.
*/
function serverBeenden() {
if (!server) return;
try {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore' });
} else {
server.kill('SIGTERM');
}
} catch {
/* egal */
}
server = null;
}
function aufraeumen() {
serverBeenden();
try {
fs.rmSync(daten, { recursive: true, force: true });
} catch {
// Unter Windows hält der Serverprozess die Datei manchmal noch kurz.
}
}
process.on('exit', aufraeumen);
/** Antwortet dort schon jemand? Dann ist es nicht unser Server. */
async function portBelegt() {
try {
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(2000) });
return true;
} catch {
return false;
}
}
/** Wartet, bis der Server antwortet. */
async function serverBereit(sekunden = 180) {
for (let versuch = 0; versuch < sekunden * 2; versuch += 1) {
if (server?.exitCode !== null && server?.exitCode !== undefined) return false;
try {
const antwort = await fetch(`${BASIS}/api/gesundheit`);
if (antwort.ok) return true;
} catch {
/* noch nicht da */
}
await new Promise((weiter) => setTimeout(weiter, 500));
}
return false;
}
function laufen(befehl, argumente, zusatz = {}) {
return spawnSync(befehl, argumente, {
env: umgebung,
encoding: 'utf8',
shell: process.platform === 'win32',
...zusatz,
});
}
try {
// --- A1 Aufsetzen aus dem Nichts -----------------------------------------
abschnitt('A1 · Aus dem Nichts aufsetzbar');
if (await portBelegt()) {
throw new Error(
`Auf ${BASIS} antwortet bereits etwas. Dieser Lauf braucht den Port für sich – ` +
'sonst prüft er gegen einen fremden Datenbestand. Bitte beenden oder ' +
'LERNBAR_ABNAHME_PORT setzen.',
);
}
const konto = laufen('npx', [
'tsx', 'scripts/konto-anlegen.ts',
'--name', AUSBILDER.name,
'--email', AUSBILDER.email,
'--rolle', 'ausbilder',
'--passwort', AUSBILDER.pw,
]);
pruefe(konto.status === 0, 'Das erste Ausbilderkonto entsteht auf der Kommandozeile', (konto.stderr || '').trim().split('\n')[0] || 'ohne Fehler');
pruefe(
fs.existsSync(path.join(daten, 'lernbar.sqlite')),
'Die Datenbank wurde angelegt und die Migrationen liefen an',
);
// Ohne Eingabeaufforderung starten: nur dann lässt sich der Prozess später
// auch wirklich beenden.
server = spawn(process.execPath, [path.join('node_modules', 'next', 'dist', 'bin', 'next'), 'start', '-p', String(PORT)], {
env: umgebung,
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverAusgabe = '';
server.stdout.on('data', (stueck) => (serverAusgabe += stueck));
server.stderr.on('data', (stueck) => (serverAusgabe += stueck));
const laeuft = await serverBereit();
pruefe(laeuft, 'Die Anwendung startet und meldet sich gesund', laeuft ? '' : serverAusgabe.slice(-400));
if (!laeuft) throw new Error('Server nicht erreichbar.');
// Die Zusage lautet: Bei einem Inhaltsfehler startet die Anwendung nicht und
// nennt Datei und Zeile. Das prüft man, indem man einen Fehler einbaut –
// nicht, indem man in der Startausgabe nach einem Wort sucht.
const heil = laufen('npx', ['tsx', 'scripts/inhalte-pruefen.ts']);
pruefe(
heil.status === 0,
'Heile Inhalte werden ohne Beanstandung gelesen',
((heil.stdout || '').match(/Inhalte in Ordnung/) ?? ['—'])[0],
);
const kaputteInhalte = path.join(daten, 'kaputte-inhalte');
fs.cpSync(path.join(process.cwd(), 'content'), kaputteInhalte, { recursive: true });
// Ein Verweis auf eine Rahmenplanposition, die es nicht gibt. Das ist der
// Fehler, der ohne Prüfung erst Monate später auffiele – in der Auswertung.
const opfer = path.join(kaputteInhalte, 'handouts', 'N3-subnetting-rechnen.md');
const zeilen = fs.readFileSync(opfer, 'utf8').split(/\r?\n/);
const stelle = zeilen.findIndex((zeile) => zeile.startsWith('rahmenplan:'));
// Wohlgeformt, aber ohne Entsprechung – so greift die Querprüfung zwischen
// den Dateien und nicht bloß die Formatprüfung.
zeilen[stelle] = 'rahmenplan: [B2, C9]';
fs.writeFileSync(opfer, zeilen.join('\n'), 'utf8');
const erwarteteZeile = stelle + 1;
const kaputt = spawnSync('npx', ['tsx', 'scripts/inhalte-pruefen.ts'], {
env: { ...umgebung, LERNBAR_INHALTE: kaputteInhalte },
encoding: 'utf8',
shell: process.platform === 'win32',
});
const meldung = `${kaputt.stdout ?? ''}${kaputt.stderr ?? ''}`;
pruefe(kaputt.status !== 0, 'Ein Inhaltsfehler lässt die Prüfung fehlschlagen', `Ende ${kaputt.status}`);
pruefe(
/N3-subnetting-rechnen\.md/.test(meldung),
'Die Meldung nennt die Datei',
(meldung.match(/.*N3-subnetting-rechnen\.md.*/) ?? ['—'])[0].trim().slice(0, 100),
);
pruefe(
meldung.includes(`N3-subnetting-rechnen.md:${erwarteteZeile}`),
'Die Meldung nennt die richtige Zeile',
`erwartet Zeile ${erwarteteZeile}`,
);
pruefe(meldung.includes('C9'), 'Die Meldung nennt den fehlerhaften Wert');
pruefe(meldung.includes('rahmenplan.yaml'), 'Die Meldung sagt, wo die gültigen Werte stehen');
browser = await chromium.launch();
// --- A2 Zwei Rollen, keine Selbstregistrierung ---------------------------
abschnitt('A2 · Zwei Rollen, keine Selbstregistrierung');
const gast = await browser.newContext({ locale: 'de-DE' });
const gastSeite = await gast.newPage();
gastSeite.setDefaultTimeout(60000);
await gastSeite.goto(`${BASIS}/anmelden`);
const anmeldetext = await gastSeite.locator('body').innerText();
pruefe(/keine Selbstregistrierung/i.test(anmeldetext), 'Die Anmeldeseite bietet keine Registrierung');
const ungeschuetzt = await gast.request.get(`${BASIS}/berichtsheft`, { maxRedirects: 0 });
pruefe(
ungeschuetzt.status() >= 300 && ungeschuetzt.status() < 400,
'Ohne Anmeldung führt jeder Pfad zur Anmeldung',
`HTTP ${ungeschuetzt.status()}`,
);
await gast.close();
const ausbilder = await browser.newContext({ locale: 'de-DE' });
const a = await ausbilder.newPage();
a.setDefaultTimeout(60000);
await a.goto(`${BASIS}/anmelden`);
await a.fill('#email', AUSBILDER.email);
await a.fill('#passwort', AUSBILDER.pw);
await a.click('button[type=submit]');
await a.waitForURL(`${BASIS}/`);
pruefe(true, 'Der Ausbilder meldet sich an');
// --- A3 Der Ausbilder legt ein Azubikonto an -----------------------------
abschnitt('A3 · Der Ausbilder legt Konten an');
await a.goto(`${BASIS}/verwaltung`);
await a.waitForSelector('#neu-name');
await a.fill('#neu-name', AZUBI.name);
await a.fill('#neu-email', AZUBI.email);
await a.fill('#neu-passwort', AZUBI.pw);
await a.fill('#neu-beginn', '2026-08-03');
await a.click('form:has(#neu-name) button[type=submit]');
await a.waitForSelector(`text=${AZUBI.name}`, { timeout: 20000 });
const verwaltungstext = await a.locator('body').innerText();
pruefe(verwaltungstext.includes(AZUBI.name), 'Der Azubi steht in der Verwaltung');
// Das Passwort darf nirgends wieder auftauchen – weder in der Ansicht noch
// in der Datenbank.
await a.reload();
const nachNeuladen = await a.content();
pruefe(!nachNeuladen.includes(AZUBI.pw), 'Das Passwort ist in der Oberfläche nirgends mehr zu sehen');
const { default: Sqlite } = await import('better-sqlite3');
// Bewusst **nicht** readonly: Eine Lesesitzung auf einer WAL-Datenbank sieht
// ohne Schreibrecht auf die Begleitdateien unter Umständen nur den letzten
// Prüfpunkt – und damit ein frisch angelegtes Konto noch nicht.
const pruefDb = new Sqlite(path.join(daten, 'lernbar.sqlite'));
const alleKonten = pruefDb.prepare('select email, rolle from benutzer').all();
const gespeichert = pruefDb.prepare('select passwort_hash from benutzer where email = ?').get(AZUBI.email);
pruefDb.close();
if (!gespeichert) {
console.log(` (in der Datenbank stehen: ${alleKonten.map((k) => `${k.email} [${k.rolle}]`).join(', ') || 'keine'})`);
}
pruefe(
Boolean(gespeichert) && !String(gespeichert.passwort_hash).includes(AZUBI.pw),
'In der Datenbank steht kein Klartextpasswort',
);
pruefe(
String(gespeichert?.passwort_hash ?? '').startsWith('$argon2id$'),
'Das Passwort liegt als Argon2id-Hash',
String(gespeichert?.passwort_hash ?? '').slice(0, 30),
);
// --- A4 Der Azubi arbeitet ------------------------------------------------
abschnitt('A4 · Der Azubi arbeitet');
const azubi = await browser.newContext({ locale: 'de-DE' });
const z = await azubi.newPage();
z.setDefaultTimeout(60000);
await z.goto(`${BASIS}/anmelden`);
await z.fill('#email', AZUBI.email);
await z.fill('#passwort', AZUBI.pw);
await z.click('button[type=submit]');
const angemeldet = await z.waitForURL(`${BASIS}/`, { timeout: 15000 }).then(() => true, () => false);
pruefe(angemeldet, 'Der Azubi meldet sich mit dem vergebenen Passwort an');
if (!angemeldet) throw new Error('Anmeldung des Azubis fehlgeschlagen.');
await z.goto(`${BASIS}/handouts/N3`);
await z.waitForSelector('h1');
await z.click('button:has-text("Als gelesen markieren")');
await z.waitForSelector('button:has-text("Doch noch nicht gelesen")', { timeout: 15000 });
pruefe(true, 'Ein Handout lässt sich als gelesen markieren');
await z.goto(`${BASIS}/karten/lernen`);
const hatKarte = (await z.locator('.lernkarte').count()) > 0;
if (hatKarte) {
await z.click('.lernkarte');
await z.click('.bewerten-sitzt');
await z.waitForTimeout(600);
const rueck = await z.locator('.lernmodus-rueckmeldung').innerText();
pruefe(/kommt/.test(rueck), 'Eine Merkkarte lässt sich bewerten', rueck.trim());
} else {
pruefe(false, 'Eine Merkkarte lässt sich bewerten', 'kein Stapel fällig');
}
// --- A5 Aufgabe abgeben und abnehmen -------------------------------------
abschnitt('A5 · Aufgabe abgeben und abnehmen');
await z.goto(`${BASIS}/aufgaben/1.1`);
await z.waitForSelector('#aufgaben-notiz');
await z.fill(
'#aufgaben-notiz',
'Arbeitsplatz nach Checkliste übergeben: Gerät angeschlossen, Anmeldung geprüft, ' +
'Drucker verbunden, Nutzer eingewiesen, Ticket geschlossen.',
);
await z.click('button:has-text("Notizen speichern")');
await z.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
pruefe(true, 'Der Azubi hält seine Arbeit in Notizen fest');
await z.click('button:has-text("Zur Abnahme")');
await z.waitForTimeout(1500);
await z.reload();
pruefe(
await z.isDisabled('button:has-text("Zur Abnahme")'),
'Der Azubi meldet die Aufgabe zur Abnahme',
);
// Der Ausbilder nimmt ab.
await a.goto(`${BASIS}/aufgaben`);
await a.waitForSelector('h1');
const zurAbnahme = await a.$('a[href*="/aufgaben/1.1?azubi="]');
pruefe(Boolean(zurAbnahme), 'Die Aufgabe erscheint beim Ausbilder zur Abnahme');
if (zurAbnahme) {
await zurAbnahme.click();
await a.waitForSelector('.kriterien', { timeout: 15000 });
for (const kasten of await a.$$('.kriterien input[type=checkbox]')) await kasten.check();
await a.fill('#bewertung', 'gut');
await a.fill('#rueckmeldung', 'Sauber dokumentiert, Einweisung nachvollziehbar.');
await a.click('button:has-text("Bewertung speichern")');
await a.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
await a.click('button:has-text("Aufgabe abnehmen")');
await a.waitForSelector('button:has-text("Bereits abgenommen")', { timeout: 20000 });
pruefe(true, 'Der Ausbilder nimmt die Aufgabe ab');
// Und danach ist sie für den Azubi gesperrt.
await z.goto(`${BASIS}/aufgaben/1.1`);
await z.waitForSelector('h1');
pruefe(
await z.isDisabled('#aufgaben-notiz'),
'Für den Azubi ist die abgenommene Aufgabe gesperrt',
);
// Eine abgenommene Aufgabe ist der belastbare Nachweis in der Abdeckung.
await a.goto(`${BASIS}/auswertung`);
await a.waitForSelector('h1');
const abdeckung = await a.locator('body').innerText();
pruefe(/vermittelt/.test(abdeckung), 'Die Abnahme schlägt in der Rahmenplanabdeckung durch');
pruefe(/ohne Inhalt|kein Inhalt/i.test(abdeckung), 'Lücken im Lehrgang werden getrennt ausgewiesen');
}
// --- A6 Rechte -----------------------------------------------------------
abschnitt('A6 · Rechte serverseitig');
const fremd = await azubi.request.get(`${BASIS}/verwaltung`, { maxRedirects: 0 });
pruefe(
fremd.status() >= 300 && fremd.status() < 400,
'Der Azubi kommt nicht in die Verwaltung',
`HTTP ${fremd.status()}`,
);
// --- A7 Berichtsheft -----------------------------------------------------
abschnitt('A7 · Berichtsheft');
await z.goto(`${BASIS}/berichtsheft`);
await z.waitForSelector('h1');
// Auf einer frischen Datenbank gibt es noch kein Blatt – der Azubi legt das
// der laufenden Woche an. Genau so fängt er im Betrieb auch an.
const anlegen = await z.$('button:has-text("Blatt für diese Woche anlegen")');
if (anlegen) {
await anlegen.click();
await z.waitForSelector('.blattzeile-link, h1', { timeout: 30000 });
await z.goto(`${BASIS}/berichtsheft`);
await z.waitForSelector('h1');
}
pruefe(
(await z.locator('.blattzeile-link').count()) > 0,
'Der Azubi legt das Wochenblatt der laufenden Woche an',
);
const blatt = await z.$('.blattzeile-link');
if (blatt) {
await blatt.click();
// Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf
// der Liste, von der wir kommen. Sonst zeigt `url()` noch dorthin – und
// die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt.
await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 });
pruefe(true, 'Ein Wochenblatt lässt sich öffnen');
const pdf = await azubi.request.get(`${z.url()}/pdf`);
const puffer = await pdf.body();
pruefe(
pdf.status() === 200 && puffer.subarray(0, 5).toString() === '%PDF-',
'Das Wochenblatt kommt als PDF heraus',
`HTTP ${pdf.status()}, ${puffer.byteLength} Bytes${
puffer.subarray(0, 5).toString() === '%PDF-' ? '' : `: „${puffer.toString('utf8').slice(0, 60)}"`
}`,
);
} else {
pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden');
}
// --- A8 Sicherung und Rückspielung ---------------------------------------
abschnitt('A8 · Sicherung zurückspielen');
// Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen.
const alsPosix = (pfad) => pfad.split(path.sep).join('/');
const sicherung = laufen('bash', ['scripts/im-container/sicherung.sh', alsPosix(path.join(daten, 'sicherungen'))]);
const archiv = (sicherung.stdout || '').trim().split('\n').pop();
pruefe(
sicherung.status === 0 && archiv && fs.existsSync(archiv),
'Die Sicherung läuft durch',
archiv ? path.basename(archiv) : (sicherung.stderr || '').trim(),
);
// Jetzt absichtlich kaputtmachen: Der Azubi verschwindet. Vorher den Server
// beenden – zurückgespielt wird bei gestoppter Anwendung, so steht es auch
// in der Anleitung.
serverBeenden();
await new Promise((weiter) => setTimeout(weiter, 2500));
const { default: Datenbank } = await import('better-sqlite3');
const dbPfad = path.join(daten, 'lernbar.sqlite');
const vorher = (() => {
const db = new Datenbank(dbPfad);
const zahl = db.prepare('select count(*) n from benutzer').get().n;
db.prepare('delete from benutzer where email = ?').run(AZUBI.email);
const nachher = db.prepare('select count(*) n from benutzer').get().n;
db.close();
return { zahl, nachher };
})();
pruefe(vorher.nachher < vorher.zahl, 'Zum Beweis wird der Datenbestand beschädigt', `${vorher.zahl} → ${vorher.nachher} Konten`);
const zurueck = laufen('bash', ['scripts/im-container/wiederherstellen.sh', alsPosix(archiv ?? ''), '--ohne-rueckfrage']);
pruefe(zurueck.status === 0, 'Die Rückspielung läuft durch', (zurueck.stderr || '').trim() || 'ohne Fehler');
const wiederDa = (() => {
const db = new Datenbank(dbPfad);
const treffer = db.prepare('select name from benutzer where email = ?').get(AZUBI.email);
const karten = db.prepare('select count(*) n from karten_verlauf').get().n;
db.close();
return { treffer, karten };
})();
pruefe(wiederDa.treffer?.name === AZUBI.name, 'Der gelöschte Azubi ist wieder da', wiederDa.treffer?.name ?? 'fehlt');
pruefe(wiederDa.karten >= 0, 'Auch der Lernverlauf kam mit zurück', `${wiederDa.karten} Einträge`);
const verdraengt = fs.readdirSync(daten).filter((name) => name.startsWith('verdraengt-'));
pruefe(verdraengt.length === 1, 'Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht', verdraengt[0] ?? '—');
} catch (ausnahme) {
console.error('\nAbbruch:', ausnahme instanceof Error ? ausnahme.message : ausnahme);
fehler += 1;
} finally {
await browser?.close();
}
// --- Protokoll -------------------------------------------------------------
const zeilen = ['# Abnahmeprotokoll', '', `Erzeugt am ${new Date().toISOString().slice(0, 16).replace('T', ' ')} Uhr`, '', 'Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.', ''];
for (const eintrag of protokoll) {
if (eintrag.abschnitt) zeilen.push('', `## ${eintrag.abschnitt}`, '');
else zeilen.push(`- ${eintrag.erfuellt ? '✓' : '✗'} ${eintrag.text}${eintrag.zusatz ? ` — ${eintrag.zusatz}` : ''}`);
}
zeilen.push('', `**${protokoll.filter((e) => e.erfuellt).length} von ${protokoll.filter((e) => 'erfuellt' in e).length} Prüfungen erfüllt.**`, '');
fs.writeFileSync(path.join(process.cwd(), 'docs', 'ABNAHME.md'), zeilen.join('\n'), 'utf8');
console.log('\nProtokoll: docs/ABNAHME.md');
console.log(`${fehler === 0 ? '✓ Abnahme bestanden.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);