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
+49
View File
@@ -26,6 +26,54 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [1.0.0] – 2026-09-11
Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die
Abnahmekriterien einzeln durchgespielt und protokolliert.
### Hinzugefügt
- **Installierbar** als Anwendung auf dem Startbildschirm: Manifest, Symbole
in drei Fassungen (darunter eine für den Beschnitt unter Android), erzeugt
aus dem eigenen Zeichen mit `npm run symbole` – ohne Bilddienst, ohne Netz
- **Offlinebetrieb** für den Fall, für den er gedacht ist: der Azubi im
Serverraum, im Keller, im Zug
- Merkkarten und schon geöffnete Handouts funktionieren weiter
- Bewertungen gehen in eine Warteschlange und werden nachgereicht, sobald
die Verbindung wieder steht. Angerechnet wird der Tag, an dem **gelernt**
wurde, nicht der Tag des Absendens
- Eine schmale Leiste sagt, wie viele Bewertungen warten – und meldet sich,
wenn sie angekommen sind
- **Nichts Bewertungsrelevantes liegt im Browserspeicher.** Weder
Lernzielkontrollen noch Berichtsheft, Bewertungen, Feedbackgespräche,
Anhänge oder PDFs. Was im Cache liegt, ist an der serverseitigen
Rechteprüfung vorbei – deshalb liegt es dort nicht
- Beim Abmelden wird alles Zwischengespeicherte weggeräumt
- **Druckansichten** mit Knöpfen bei Handout, Glossar und Auswertung
- **`docs/ANLEITUNG.md`** – Anleitung mit 27 Bildschirmfotos, für Ausbilder
und Azubi getrennt, dazu ein Abschnitt „Wenn etwas nicht geht"
- **`scripts/pruefungen/abnahme.mjs`** – der Abnahmelauf gegen eine frische,
leere Datenbank in einem Wegwerfverzeichnis: aufsetzen, Konten anlegen,
arbeiten, abnehmen, sichern, beschädigen, zurückspielen. Das Protokoll
landet in `docs/ABNAHME.md`
- `scripts/pruefungen/baustein-10.mjs` – Durchstich mit echtem Abschalten des
Netzes
### Behoben
- **Im Druck wurde die dunkle Fassung mitgedruckt.** Wer dunkel eingestellt
hatte, bekam schwarze Flächen aufs Papier. Für den Druck wird jetzt die
vollständige helle Palette gesetzt, dazu A4-Ränder, keine Umbrüche mitten in
Karten und Zeilen, und aufklappbare Abschnitte drucken aufgeklappt
- **Die Sicherungsskripte scheiterten an Pfaden mit Doppelpunkt.** `tar` deutet
einen Doppelpunkt im Dateinamen als Rechnernamen – aus `C:\...` wurde der
Rechner `C`. Beide Skripte wechseln jetzt erst ins Verzeichnis und
übergeben einen blossen Dateinamen. Im Kopf der Skripte stand, sie liefen
auch in Git Bash; das stimmte bis jetzt nicht
- Das Skript für die Bildschirmfotos suchte Wochenblatt und
Checklisten-Durchlauf auf der Kontrollseite, wo es sie nicht gibt – zwei
Bilder waren seit Wochen veraltet
## [0.9.0] – 2026-09-11
Auswertung, Rahmenplanabdeckung und das Feedbackgespräch.
@@ -401,6 +449,7 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[1.0.0]: #100--2026-09-11
[0.9.0]: #090--2026-09-11
[0.8.0]: #080--2026-09-11
[0.7.0]: #070--2026-09-10
+49
View File
@@ -7,6 +7,55 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-11 · Baustein 10 – PWA, Offline, Druck, Anleitung
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 14:20 | 14:50 | 0:30 | Symbole, Manifest, Dienstarbeiter mit Speicherregel |
| 14:50 | 15:10 | 0:20 | Warteschlange, Schnittstelle, Netzleiste |
| 15:10 | 15:30 | 0:20 | Druckpalette, Seitenumbrüche, Druckknöpfe |
| 15:30 | 15:50 | 0:20 | Durchstich mit echtem Abschalten des Netzes |
| 15:50 | 16:20 | 0:30 | Anleitung mit Bildschirmfotos |
| 16:20 | 17:40 | 1:20 | Abnahmelauf – und die Suche nach einem Fehler, der meiner war |
**Summe Baustein 10: rund 3 h 20 min**
### Was aufgefallen ist
- Beim Offlinebetrieb ist die interessante Frage nicht, **wie** man
zwischenspeichert, sondern **was**. Ein Dienstarbeiter, der alles ablegt,
hebelt die Rechteprüfung aus: Was im Browsercache liegt, wird nie wieder
serverseitig geprüft. Deshalb gibt es eine ausdrückliche Liste – Handouts,
Merkkarten, Glossar, eigene Woche – und sonst nichts. Der Durchstich prüft
das nach, indem er eine Kontrollseite öffnet und danach im Speicher
nachsieht.
- Die Warteschlange gehört an die **Benutzerkennung**, nicht an das Gerät. Ein
Werkstattrechner wird geteilt; unter einem festen Schlüssel bekäme der
Zweite die offenen Bewertungen des Ersten aufs Konto.
- Der Tag einer nachgereichten Bewertung kommt vom Gerät – sonst verliert
jemand, der freitags im Keller lernt und sich montags verbindet, seinen
Freitag. Er wird aber **auf heute gedeckelt**: Sonst ließe sich das
Wiederholen wegstellen, indem man die Uhr vordatiert.
- Beim Druck lag ein echter Fehler: Wer die dunkle Fassung eingestellt hatte,
druckte schwarze Flächen. Aufgefallen ist das erst, weil der Durchstich die
Farben im Druckmodus ausliest, statt nur zu prüfen, dass die Navigation
verschwindet.
- **Die längste Suche des Tages war ein Fehler in meinem Prüfaufbau, nicht in
der Anwendung.** Der Abnahmelauf startet einen eigenen Server auf Port 3399.
Unter Windows beendet `kill()` bei einem über die Eingabeaufforderung
gestarteten Prozess nur diese, nicht Node darunter. Der alte Server überlebte,
belegte den Port, und der nächste Lauf prüfte gegen den **alten**
Datenbestand: Der Azubi stand in der Oberfläche, aber nicht in der Datenbank,
die ich las. Jetzt wird ohne Eingabeaufforderung gestartet, der ganze
Prozessbaum beendet – und der Lauf bricht mit einer klaren Meldung ab, wenn
der Port schon belegt ist.
- Die Sicherungsskripte behaupteten im Kopf, sie liefen auch in Git Bash. Das
stimmte nicht: `tar` deutet den Doppelpunkt in `C:\...` als Rechnernamen.
Aufgefallen ist das erst, als die Sicherung zum ersten Mal wirklich
zurückgespielt wurde – eine Sicherung, die man nie zurückspielt, ist keine.
---
## 2026-09-11 · Baustein 9 – Auswertung und Feedbackgespräch
| Von | Bis | Dauer | Tätigkeit |
+61
View File
@@ -410,3 +410,64 @@ sondern die Stellen, an denen sie auseinandergehen.
Bestätigen können beide einzeln; erst wenn **beide** bestätigt haben, ist das
Gespräch unveränderlich. Solange nur eine Seite bestätigt hat, lässt sich die
Bestätigung zurücknehmen.
## E38 · Was offline liegen darf – und was niemals
Die Regel steht in `public/sw.js`, in einer einzigen Funktion:
`darfOfflineLiegen()`.
**Offline liegt nur Lernmaterial:** Handouts, Merkkarten, Glossar, die eigene
Woche. **Nicht** Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespräche, Anhänge und kein PDF.
Der Grund ist keine Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft:
Musterlösungen und Bewertungsdaten stehen hinter einer eigenen Prüfung. Diese
Prüfung findet auf dem Server statt. Was im Browserspeicher liegt, wird beim
nächsten Zugriff nicht mehr geprüft – es wird ausgeliefert. Ein Dienstarbeiter,
der „alles" zwischenspeichert, hebelt damit genau die Zusage aus, die am
schwersten wiegt.
Seiten werden nach dem Muster **erst das Netz, dann der Speicher** geholt:
Nach einer Aktualisierung sieht man sofort die neue Fassung, und nur bei
fehlendem Netz die alte. Dateien unter `/_next/static/` tragen eine Prüfsumme
im Namen und werden zuerst aus dem Speicher bedient.
Beim Abmelden wird alles Persönliche weggeräumt – ein Werkstattgerät wird
geteilt.
## E39 · Die Warteschlange gehört zur Anmeldung, nicht zum Gerät
Offline entstandene Bewertungen liegen unter
`lernbar-warteschlange-<benutzerId>` im `localStorage`.
Unter einem festen Schlüssel bekäme auf einem geteilten Gerät der Zweite die
offenen Bewertungen des Ersten auf sein Konto geschrieben – und der Erste hätte
sie nie. Beim Abmelden wird die Schlange deshalb auch **nicht** gelöscht: Sie
gehört zur Anmeldung und wird beim nächsten Mal nachgereicht. Verworfen würde
sonst eine Lernleistung, die jemand erbracht hat.
`localStorage` und nicht IndexedDB: Es geht um eine Handvoll Einträge zwischen
zwei Verbindungen. Geht der Speicher verloren, ist eine Karte falsch
terminiert – dafür lohnt kein zweites Datenmodell.
## E40 · Der Tag einer nachgereichten Bewertung
`tagDerBewertung()` in `src/lib/offline/nachtrag.ts`, mit eigenen Tests.
Der Tag kommt vom **Gerät**, weil nur dort bekannt ist, wann tatsächlich
gelernt wurde: Wer freitags im Keller lernt und sich montags wieder verbindet,
soll seinen Freitag angerechnet bekommen.
Er wird auf **heute gedeckelt**. Sonst ließe sich das Wiederholen wegstellen,
indem man die Uhr des Geräts vordatiert – die Karte käme dann erst in ferner
Zukunft wieder. Unlesbares gilt als heute.
## E41 · Im Druck gilt immer die helle Fassung
Die vollständige helle Palette wird unter `@media print` noch einmal gesetzt,
und zwar auch für `:root[data-thema="dunkel"]`.
Der Grund ist banal und war ein echter Fehler: Wer die dunkle Fassung
eingestellt hatte, druckte schwarze Flächen. Eine einzelne Regel für `body`
genügt nicht, weil die Karten, Ränder und Marken ihre Farben aus Merkmalen
beziehen.
+42 -6
View File
@@ -9,10 +9,12 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
> **Stand:** Bausteine 1 bis 9 von 10 sind fertig. Es fehlt nur noch der
> Feinschliff: PWA, Offlinebetrieb, Druckansichten und die Anleitung mit
> Screenshots. Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
> **Stand:** Alle zehn Bausteine sind fertig – Fassung 1.0.0.
>
> - [**Anleitung mit Bildschirmfotos**](docs/ANLEITUNG.md) – wie man damit arbeitet
> - [Abnahmeprotokoll](docs/ABNAHME.md) – die Abnahmekriterien, einzeln durchgespielt
> - [Änderungsprotokoll](CHANGELOG.md) · [Fahrplan](ROADMAP.md) ·
> [Entscheidungen](ENTSCHEIDUNGEN.md)
---
@@ -240,10 +242,44 @@ Protokollzeilen.
```bash
npm run typecheck # TypeScript
npm run lint # ESLint
npm run test # Vitest – Logik, die wehtut
npm run e2e # Playwright – der Hauptweg von Anfang bis Ende
npm run test # Vitest – Logik, die wehtut (315 Tests)
npm run abnahme # der Abnahmelauf, siehe unten
```
### Der Abnahmelauf
`npm run abnahme` ist die Prüfung, auf die es ankommt. Er legt ein
**Wegwerfverzeichnis** an, startet die Anwendung mit einer **frischen, leeren
Datenbank** und spielt den ganzen Weg durch:
1. Migrationen laufen an, das erste Ausbilderkonto entsteht auf der Kommandozeile
2. Die Inhaltsprüfung wird mit einem absichtlich eingebauten Fehler auf die
Probe gestellt – sie muss Datei, Zeile und Wert nennen
3. Der Ausbilder legt ein Azubikonto an; das Passwort darf danach nirgends mehr
auftauchen, in der Datenbank steht ein Argon2id-Hash
4. Der Azubi liest ein Handout, lernt eine Karte, meldet eine Aufgabe zur Abnahme
5. Der Ausbilder nimmt sie ab; sie schlägt in der Rahmenplanabdeckung durch
6. Das Wochenblatt wird angelegt und als PDF ausgegeben
7. **Gesichert, absichtlich beschädigt, zurückgespielt.** Eine Sicherung, die
nie zurückgespielt wurde, ist keine Sicherung
Das Protokoll landet in [`docs/ABNAHME.md`](docs/ABNAHME.md).
Voraussetzung: `npm run build` ist gelaufen, und Port 3399 ist frei – sonst
bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu
prüfen.
### Einzelne Zusagen
```bash
node scripts/pruefungen/musterloesung-dichtheit.mjs # Musterlösung dicht?
node scripts/pruefungen/baustein-9.mjs # Abdeckung, Feedback
node scripts/pruefungen/baustein-10.mjs # PWA, Offline, Druck
```
Diese drei brauchen eine **laufende** Anwendung unter `http://localhost:3000`
mit den Beispieldaten.
---
## Aufbau
+10 -4
View File
@@ -105,11 +105,17 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## 1.0 – Abrundung
- [ ] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
- [ ] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
- [x] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
- [x] Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt
- [x] Offline: Merkkarten und gelesene Handouts, Bewertungen in einer
Warteschlange, nachgereicht bei Verbindung
- [x] Nichts Bewertungsrelevantes im Browserspeicher – nachgewiesen
- [x] Druckansichten, auch bei eingestellter dunkler Fassung
- [x] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
über `npm run screenshots`
- [ ] Ende-zu-Ende-Test über den Hauptweg
- [ ] Abnahmekriterien einzeln durchgespielt und protokolliert
- [x] Ende-zu-Ende-Test über den Hauptweg (`scripts/pruefungen/abnahme.mjs`)
- [x] Abnahmekriterien einzeln durchgespielt und protokolliert
(`docs/ABNAHME.md`)
---
+68
View File
@@ -0,0 +1,68 @@
# Abnahmeprotokoll
Erzeugt am 2026-09-11 17:16 Uhr
Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.
## A1 · Aus dem Nichts aufsetzbar
- ✓ Das erste Ausbilderkonto entsteht auf der Kommandozeile — ohne Fehler
- ✓ Die Datenbank wurde angelegt und die Migrationen liefen an
- ✓ Die Anwendung startet und meldet sich gesund
- ✓ Heile Inhalte werden ohne Beanstandung gelesen — Inhalte in Ordnung
- ✓ Ein Inhaltsfehler lässt die Prüfung fehlschlagen — Ende 1
- ✓ Die Meldung nennt die Datei — kaputte-inhalte/handouts/N3-subnetting-rechnen.md:7 (rahmenplan)
- ✓ Die Meldung nennt die richtige Zeile — erwartet Zeile 7
- ✓ Die Meldung nennt den fehlerhaften Wert
- ✓ Die Meldung sagt, wo die gültigen Werte stehen
## A2 · Zwei Rollen, keine Selbstregistrierung
- ✓ Die Anmeldeseite bietet keine Registrierung
- ✓ Ohne Anmeldung führt jeder Pfad zur Anmeldung — HTTP 307
- ✓ Der Ausbilder meldet sich an
## A3 · Der Ausbilder legt Konten an
- ✓ Der Azubi steht in der Verwaltung
- ✓ Das Passwort ist in der Oberfläche nirgends mehr zu sehen
- ✓ In der Datenbank steht kein Klartextpasswort
- ✓ Das Passwort liegt als Argon2id-Hash — $argon2id$v=19$m=19456,t=2,p=1
## A4 · Der Azubi arbeitet
- ✓ Der Azubi meldet sich mit dem vergebenen Passwort an
- ✓ Ein Handout lässt sich als gelesen markieren
- ✓ Eine Merkkarte lässt sich bewerten — Zuletzt: kommt morgen.
## A5 · Aufgabe abgeben und abnehmen
- ✓ Der Azubi hält seine Arbeit in Notizen fest
- ✓ Der Azubi meldet die Aufgabe zur Abnahme
- ✓ Die Aufgabe erscheint beim Ausbilder zur Abnahme
- ✓ Der Ausbilder nimmt die Aufgabe ab
- ✓ Für den Azubi ist die abgenommene Aufgabe gesperrt
- ✓ Die Abnahme schlägt in der Rahmenplanabdeckung durch
- ✓ Lücken im Lehrgang werden getrennt ausgewiesen
## A6 · Rechte serverseitig
- ✓ Der Azubi kommt nicht in die Verwaltung — HTTP 307
## A7 · Berichtsheft
- ✓ Der Azubi legt das Wochenblatt der laufenden Woche an
- ✓ Ein Wochenblatt lässt sich öffnen
- ✓ Das Wochenblatt kommt als PDF heraus — HTTP 200, 2646 Bytes
## A8 · Sicherung zurückspielen
- ✓ Die Sicherung läuft durch — lernbar-20260911-171627.tar.gz
- ✓ Zum Beweis wird der Datenbestand beschädigt — 2 → 1 Konten
- ✓ Die Rückspielung läuft durch — ohne Fehler
- ✓ Der gelöschte Azubi ist wieder da — Tim Vogt
- ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge
- ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-20260911-171632
**36 von 36 Prüfungen erfüllt.**
+412
View File
@@ -0,0 +1,412 @@
# Lern-Bar – Anleitung
Diese Anleitung zeigt die Anwendung so, wie sie im Betrieb benutzt wird: erst
der Ausbilder, dann der Azubi. Wer die Lern-Bar aufsetzen will, findet das im
[README](../README.md) – hier geht es um den Umgang damit.
Die Bilder stammen aus einer laufenden Anwendung mit den Beispielinhalten und
werden mit `npm run screenshots` neu erzeugt. Was hier steht, ist also nicht
gemalt, sondern gemessen.
---
## Inhalt
**Für den Ausbilder**
1. [Anmelden](#1-anmelden)
2. [Ein Azubikonto anlegen](#2-ein-azubikonto-anlegen)
3. [Den Wochenplan festlegen](#3-den-wochenplan-festlegen)
4. [Eine Praxisaufgabe abnehmen](#4-eine-praxisaufgabe-abnehmen)
5. [Das Berichtsheft abzeichnen](#5-das-berichtsheft-abzeichnen)
6. [Eine Lernzielkontrolle korrigieren](#6-eine-lernzielkontrolle-korrigieren)
7. [Die Rahmenplanabdeckung lesen](#7-die-rahmenplanabdeckung-lesen)
8. [Ein Feedbackgespräch führen](#8-ein-feedbackgespräch-führen)
**Für den Azubi**
9. [Die Woche](#9-die-woche)
10. [Handouts lesen und suchen](#10-handouts-lesen-und-suchen)
11. [Merkkarten lernen](#11-merkkarten-lernen)
12. [Eine Praxisaufgabe abgeben](#12-eine-praxisaufgabe-abgeben)
13. [Eine Checkliste abarbeiten](#13-eine-checkliste-abarbeiten)
14. [Das Berichtsheft führen](#14-das-berichtsheft-führen)
15. [Eine Lernzielkontrolle schreiben](#15-eine-lernzielkontrolle-schreiben)
**Überall**
16. [Auf dem Handy](#16-auf-dem-handy)
17. [Ohne Netz](#17-ohne-netz)
18. [Hell und dunkel](#18-hell-und-dunkel)
19. [Drucken](#19-drucken)
20. [Wenn etwas nicht geht](#20-wenn-etwas-nicht-geht)
---
## Für den Ausbilder
### 1. Anmelden
![Anmeldung](bilder/01-anmelden.png)
Es gibt **keine Selbstregistrierung**. Das erste Ausbilderkonto legst du auf der
Kommandozeile an (siehe README, Abschnitt *In fünf Minuten zum laufenden
System*), alle weiteren Konten entstehen in der Verwaltung.
Nach dem Anmelden landest du auf deinem Überblick: was in dieser Woche ansteht,
was offen ist, was auf dich wartet.
![Überblick des Ausbilders](bilder/02-ausbilder-ueberblick.png)
---
### 2. Ein Azubikonto anlegen
**Verwaltung → Neues Azubikonto**
![Verwaltung](bilder/03-verwaltung.png)
Du gibst Name, E-Mail-Adresse, ein erstes Passwort (mindestens 10 Zeichen) und
den Ausbildungsbeginn an; Probezeit und geplantes Ende sind freiwillig.
Das Passwort gibst du dem Azubi **persönlich** weiter – er kann es danach selbst
ändern. Einsehbar ist es danach nirgends mehr, auch für dich nicht: In der
Datenbank liegt nur ein Argon2id-Hash. Hat jemand sein Passwort vergessen,
setzt du in der Verwaltung ein neues; das alte lässt sich nicht anzeigen.
Der Ausbildungsbeginn ist wichtiger, als er aussieht. Aus ihm ergeben sich das
Ausbildungsjahr und damit die Wochennummern im Berichtsheft.
> **Ein Azubi hat genau einen Ausbilder.** Das ist keine Einschränkung, die sich
> später aufheben ließe, sondern die Grundlage der Rechteprüfung: Der Azubi
> sieht ausschließlich seine eigenen Daten, der Ausbilder die seiner Azubis.
---
### 3. Den Wochenplan festlegen
**Wochenplan**
![Wochenplan](bilder/04-wochenplan.png)
Die Wochen selbst stehen als Dateien unter `content/wochen/`. Hier legst du
fest, **wann** welche Woche läuft, und kannst sie verschieben – Urlaub,
Berufsschulblock, ein Projekt, das dazwischenkommt.
Verschiebst du eine Woche, wandern die folgenden mit. Was der Azubi schon
erledigt hat, bleibt erledigt.
---
### 4. Eine Praxisaufgabe abnehmen
**Aufgaben → eine abgegebene Aufgabe öffnen**
![Aufgabe abnehmen](bilder/05-aufgabe-abnahme.png)
Der Azubi gibt eine Aufgabe mit seiner Lösung und optional mit Anhängen ab –
meist Fotos vom Handy. Du siehst die Abnahmekriterien aus der Aufgabendatei und
hakst ab, was erfüllt ist.
Eine Aufgabe ist **abgenommen**, wenn du sie abgenommen hast. Nicht, wenn der
Azubi sie abgegeben hat. Genau dieser Unterschied macht sie später in der
Rahmenplanabdeckung zu einem belastbaren Nachweis.
Reicht etwas nicht, gibst du sie mit einer Bemerkung zurück. Der Azubi sieht,
was fehlt, und kann nachbessern.
---
### 5. Das Berichtsheft abzeichnen
**Berichtshefte → ein Wochenblatt öffnen**
![Wochenblatt](bilder/22-wochenblatt.png)
Der Ausbildungsnachweis ist der einzige Teil dieser Anwendung, den das Gesetz
verlangt. Deshalb ist er der sorgfältigste.
Du liest das Blatt, siehst die Warnhinweise (leere Tage, Floskeln, mögliche
Betriebs- oder Personennamen) und zeichnest ab. **Nach dem Abzeichnen ist das
Blatt unveränderlich** – auch für dich. Willst du eine Änderung, gibst du es
frei; das wird protokolliert.
Über *Als PDF* bekommst du das Blatt im IHK-Layout, mit Unterschriftenfeldern.
Ein Beispiel liegt unter [`docs/beispiel-ausbildungsnachweis.pdf`](beispiel-ausbildungsnachweis.pdf).
---
### 6. Eine Lernzielkontrolle korrigieren
**Kontrollen**
![Kontrollen](bilder/23-kontrollen.png)
Du gibst eine Kontrolle frei, der Azubi schreibt sie, du korrigierst. Punkte je
Frage, daraus Prozent, daraus die Note nach dem **IHK-Notenschlüssel**
(92/81/67/50/30 %).
![Ergebnis einer Kontrolle](bilder/24-kontrolle-ergebnis.png)
> **Die Musterlösung bekommt der Azubi erst nach dem Absenden zu sehen.** Vorher
> ist sie über keine Schnittstelle abrufbar – nicht im HTML, nicht in der
> Nachladung, nirgends. Das wird serverseitig entschieden, nicht in der
> Oberfläche, und mit einem eigenen Prüfskript nachgewiesen
> (`scripts/pruefungen/musterloesung-dichtheit.mjs`).
---
### 7. Die Rahmenplanabdeckung lesen
**Auswertung**
![Auswertung](bilder/06-auswertung.png)
Das ist die Ansicht, die gegenüber der IHK zählt. Sie beantwortet die Frage, die
im Prüfungsausschuss gestellt wird: *Ist der Ausbildungsrahmenplan abgedeckt?*
Statt „erledigt / offen" gibt es **vier Lagen**:
| Lage | Bedeutung |
|---|---|
| **vermittelt** | eine abgenommene Praxisaufgabe oder ein abgeschlossener Checklisten-Durchlauf, **oder** mindestens zwei gelesene Handouts |
| **angefangen** | etwas erledigt, aber noch nicht genug – ein gelesenes Handout allein genügt bewusst nicht |
| **geplant** | Material vorhanden, aber unbearbeitet |
| **ohne Inhalt** | zu dieser Position gibt es im ganzen Lehrgang nichts |
Die vierte Lage ist die wichtigste und wird rot ausgewiesen:
![Positionen ohne Inhalt](bilder/07-rahmenplanabdeckung.png)
**Das ist keine Lücke beim Azubi, sondern eine im Lehrgang.** Dort hilft kein
Lernen, sondern nur ein neues Handout oder eine neue Aufgabe unter `content/`.
In den Beispielinhalten betrifft das fünf Positionen: A4, A10, C1, C2 und C5.
Jede Position führt ihre einzelnen Nachweise mit Kürzel und Zustand auf und
nennt ihre amtliche Fundstelle in der Verordnung. Wenn jemand fragt, worauf sich
die Bewertung stützt, steht die Antwort da.
---
### 8. Ein Feedbackgespräch führen
**Feedback → Neues Gespräch anlegen**
![Feedbackgespräche](bilder/08-feedback-liste.png)
Monatlich, mit Leitfaden und Bewertungsbogen. Der Ablauf:
1. Du legst das Gespräch an und trägst **deine** Einschätzung ein: acht Kriterien
auf einer Skala von 1 bis 5, dazu Stärken, Entwicklungsfelder und die
vereinbarten Ziele.
2. Der Azubi trägt **seine** Selbsteinschätzung ein. Er sieht deine Freitexte,
kann sie aber nicht ändern – und du kannst seine Selbsteinschätzung nicht
eintragen. Eine Selbsteinschätzung, die der Ausbilder eintragen könnte, wäre
keine.
3. Wo beide um **zwei Stufen oder mehr** auseinanderliegen, wird das
hervorgehoben. Genau darüber lohnt das Gespräch – nicht über die Zahlen,
sondern über die Stellen, an denen sie auseinandergehen.
4. Beide bestätigen. Danach ist das Gespräch **unveränderlich**.
![Feedbackgespräch](bilder/09-feedbackgespraech.png)
*Als PDF für die Akte* gibt beide Einschätzungen nebeneinander aus, mit
Unterschriftenfeldern. Abweichungen sind auch dort markiert.
> Die Kriterien und der Leitfaden stehen in
> `content/feedbackgespraech.yaml` und lassen sich an den eigenen Betrieb
> anpassen. Ein bereits geführtes Gespräch behält seine Fassung, auch wenn du
> die Vorlage später änderst.
---
## Für den Azubi
### 9. Die Woche
![Woche des Azubis](bilder/10-azubi-woche.png)
Die Startseite zeigt genau eine Woche: das Thema, was diese Woche zu tun ist,
was schon erledigt ist, was drängt. Kein Dashboard mit zwölf Kacheln – die
Frage lautet „Was mache ich jetzt?", nicht „Wie ist meine Gesamtlage?".
---
### 10. Handouts lesen und suchen
![Handouts](bilder/11-handouts-liste.png)
Die Handouts sind der Lesestoff: kurz, mit Beispielen, jeweils einem Thema
zugeordnet. Die Suche geht über Titel und Volltext.
![Suche](bilder/12-handouts-suche.png)
![Ein Handout](bilder/13-handout-n3.png)
Unten markierst du ein Handout als gelesen. Das ist keine Spielerei: In der
Auswertung zählen gelesene Handouts als **stützende** Nachweise für die
Rahmenplanabdeckung.
Fachbegriffe, die dir unterwegs begegnen, stehen im **Glossar** – kurz erklärt,
mit Querverweisen.
![Glossar](bilder/15-glossar.png)
---
### 11. Merkkarten lernen
![Merkkarten](bilder/14-merkkarten.png)
Die Karten gehören zu den Handouts und kommen nach dem Prinzip der
**Wiederholung nach Zeitabstand** wieder: Was sitzt, kommt später wieder; was
nicht sitzt, heute noch einmal.
Im Lernmodus:
- **Antippen** oder **Leertaste** – Karte umdrehen
- **1 / 2 / 3** – nochmal / unsicher / sitzt
- **Wischen** nach rechts für „sitzt", nach links für „nochmal"
Zehn Minuten am Tag reichen. Wer das durchhält, hat vor der Prüfung nichts
nachzuholen.
---
### 12. Eine Praxisaufgabe abgeben
![Aufgaben](bilder/16-aufgaben-liste.png)
![Eine Aufgabe](bilder/17-aufgabe-azubi.png)
Du liest den Auftrag, siehst die Abnahmekriterien vorher – du weißt also, woran
du gemessen wirst –, schreibst deine Lösung und hängst an, was dazugehört.
Fotos vom Handy gehen direkt.
Dann gibst du ab. Der Ausbilder nimmt ab oder gibt zurück, mit Bemerkung.
---
### 13. Eine Checkliste abarbeiten
![Checklisten](bilder/18-checklisten.png)
Checklisten sind für Abläufe, die immer gleich gehen: einen Arbeitsplatz
übergeben, Windows installieren, einen Rechner zerlegen. Du startest einen
Durchlauf und hakst Schritt für Schritt ab.
![Ein Durchlauf](bilder/19-checklisten-lauf.png)
Ein Durchlauf **kopiert die Schritte bei seinem Start**. Ändert jemand die
Checkliste, während du sie abarbeitest, bleibt dein Durchlauf so, wie du ihn
begonnen hast. Am Ende gibt es ein PDF – ein Beispiel liegt unter
[`docs/beispiel-checkliste.pdf`](beispiel-checkliste.pdf).
---
### 14. Das Berichtsheft führen
![Berichtsheft](bilder/21-berichtsheft.png)
Pro Kalenderwoche ein Blatt, gegliedert nach Tagen, mit Stunden. Die Anwendung
hilft an drei Stellen:
- **Vorschläge** aus dem, was du in der Woche tatsächlich gemacht hast:
abgenommene Aufgaben, abgeschlossene Checklisten, gelesene Handouts.
- **Prüfung vor dem Abgeben**: leere Tage, Floskeln („diverse Tätigkeiten",
„wie immer"), verdächtige Namen von Firmen oder Personen. Der
Ausbildungsnachweis geht an die IHK – da haben Kundennamen nichts verloren.
- **Formulierungshilfe**: aus „Drucker repariert" wird „Fehlerbild aufgenommen,
Fixiereinheit getauscht, Testdruck geprüft, Ticket dokumentiert".
Nach dem Abzeichnen ist das Blatt unveränderlich.
---
### 15. Eine Lernzielkontrolle schreiben
Der Ausbilder gibt die Kontrolle frei, du siehst sie unter *Kontrollen*. Du
schreibst deine Antworten und sendest ab.
**Erst nach dem Absenden** siehst du die Musterlösung – vorher nicht, und auch
nicht über Umwege. Das ist keine Gängelei, sondern der Sinn der Sache: Eine
Kontrolle, deren Lösung vorher abrufbar wäre, misst nichts.
---
## Überall
### 16. Auf dem Handy
Die Anwendung ist für das Handy gebaut, nicht dafür angepasst. Der Azubi hat es
in der Tasche, nicht den Laptop.
| Handout | Lernmodus, Frage | Lernmodus, Antwort |
|---|---|---|
| ![Handout auf dem Handy](bilder/30-handy-handout.png) | ![Frage](bilder/31-handy-lernmodus-frage.png) | ![Antwort](bilder/32-handy-lernmodus-antwort.png) |
**Auf den Startbildschirm legen:** Die Lern-Bar ist eine installierbare
Anwendung.
- *Android (Chrome):* Menü → **App installieren**
- *iPhone (Safari):* Teilen → **Zum Home-Bildschirm**
Danach startet sie ohne Adresszeile und mit eigenem Symbol.
---
### 17. Ohne Netz
Im Serverraum, im Keller, im Zug gibt es kein Netz. Deshalb:
- **Merkkarten** und **Handouts, die du schon einmal geöffnet hattest**,
funktionieren weiter.
- Deine **Bewertungen werden gemerkt** und gehen ab, sobald du wieder verbunden
bist. Eine schmale Leiste sagt dir, wie viele warten – und meldet sich, wenn
sie angekommen sind.
- Der Tag zählt, an dem du **gelernt** hast, nicht der Tag des Absendens.
Was **nicht** offline liegt: Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespräche, Anhänge und jedes PDF. Das ist Absicht. Diese Dinge stehen
hinter einer eigenen Rechteprüfung, und was im Browserspeicher liegt, ist an
dieser Prüfung vorbei.
Auf einem geteilten Gerät wird beim Abmelden alles Zwischengespeicherte
weggeräumt.
---
### 18. Hell und dunkel
![Dunkle Fassung](bilder/20-dunkel.png)
Oben rechts in der Kopfzeile schaltest du um. Die Einstellung bleibt auf dem
Gerät. Beim Drucken wird immer hell gedruckt, auch wenn du dunkel eingestellt
hast – sonst käme schwarzes Papier aus dem Drucker.
---
### 19. Drucken
Für die Dokumente, die eine Unterschrift tragen, gibt es **PDFs**:
Wochenblatt, Checklisten-Durchlauf, Feedbackgespräch.
Für alles andere gibt es einen **Druckknopf**: Handout, Glossar, Auswertung. Die
Navigation, Knöpfe und Hinweise werden nicht mitgedruckt, Karten werden nicht
mitten durchgeschnitten, aufklappbare Abschnitte drucken aufgeklappt.
---
### 20. Wenn etwas nicht geht
| Beobachtung | Woran es liegt |
|---|---|
| „Diese Seite gibt es nicht" bei fremden Daten | Kein Fehler, sondern die Rechteprüfung. Ob es zu einer Kennung überhaupt Daten gibt, geht Unbefugte nichts an – deshalb 404 und nicht „kein Zugriff". |
| Die Anwendung startet nicht, nennt aber Datei und Zeile | Ein Inhaltsfehler unter `content/`. Die Meldung sagt genau, wo. `npm run inhalte:pruefen` prüft ohne Start. |
| Ein Anhang lässt sich nicht hochladen | Über 25 MB. Bei einem Reverse Proxy zusätzlich `client_max_body_size 30m` setzen (siehe README). |
| Ein abgezeichnetes Blatt lässt sich nicht ändern | So gewollt. Der Ausbilder kann es freigeben; das wird protokolliert. |
| Offline erscheint „Kein Netz" | Diese Seite lag nicht auf dem Gerät. Merkkarten und schon geöffnete Handouts gehen weiter. |
Bleibt etwas offen: `docker compose logs -f` zeigt, was der Server sagt, und
`/api/gesundheit` antwortet nur dann mit 200, wenn die Datenbank ansprechbar
ist.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 103 KiB

+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "lernbar",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
"engines": {
@@ -20,7 +20,8 @@
"backup": "bash scripts/im-container/sicherung.sh",
"restore": "bash scripts/im-container/wiederherstellen.sh",
"screenshots": "tsx scripts/screenshots.ts",
"symbole": "tsx scripts/symbole.ts"
"symbole": "tsx scripts/symbole.ts",
"abnahme": "node scripts/pruefungen/abnahme.mjs"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
+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);