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>
This commit is contained in:
Florian Wilde
2026-09-11 19:26:34 +02:00
co-authored by Claude Opus 5
parent 2babef9293
commit c61d5711e0
21 changed files with 1201 additions and 15 deletions
+7 -2
View File
@@ -46,7 +46,12 @@ Inhalt: lernbar.sqlite (Datenbank), anhaenge/ (hochgeladene Dateien)
Zurückspielen: wiederherstellen.sh <diese-datei.tar.gz>
ENDE
ARCHIV="$ZIEL/lernbar-$STEMPEL.tar.gz"
tar -czf "$ARCHIV" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt
# Bewusst erst ins Zielverzeichnis wechseln und dann einen blossen Dateinamen
# uebergeben: tar deutet einen Doppelpunkt im Dateinamen als Rechnernamen
# ("C:\..." wird zu Rechner "C"). Das faellt nur auf Windows auf, aber ein
# Pfad mit Doppelpunkt kann ueberall vorkommen.
NAME="lernbar-$STEMPEL.tar.gz"
ARCHIV="$ZIEL/$NAME"
( cd "$ZIEL" && tar -czf "$NAME" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt )
echo "$ARCHIV"
+4 -1
View File
@@ -36,7 +36,10 @@ fi
ARBEIT="$(mktemp -d)"
trap 'rm -rf "$ARBEIT"' EXIT
tar -xzf "$ARCHIV" -C "$ARBEIT"
# Siehe sicherung.sh: tar darf den Doppelpunkt im Pfad nicht zu sehen bekommen.
VERZEICHNIS="$(cd "$(dirname "$ARCHIV")" && pwd)"
DATEI="$(basename "$ARCHIV")"
( cd "$VERZEICHNIS" && tar -xzf "$DATEI" -C "$ARBEIT" )
if [ ! -f "$ARBEIT/lernbar.sqlite" ]; then
echo "Das Archiv enthält keine lernbar.sqlite – falsche Datei?" >&2
+496
View File
@@ -0,0 +1,496 @@
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);