diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d40c9..1e6655a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DEVLOG.md b/DEVLOG.md index b6d364d..9956018 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 | diff --git a/ENTSCHEIDUNGEN.md b/ENTSCHEIDUNGEN.md index 51b3816..9082083 100644 --- a/ENTSCHEIDUNGEN.md +++ b/ENTSCHEIDUNGEN.md @@ -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-` 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. diff --git a/README.md b/README.md index 9f762f5..32c56bc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 3491559..3ea624a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`) --- diff --git a/docs/ABNAHME.md b/docs/ABNAHME.md new file mode 100644 index 0000000..b03db9d --- /dev/null +++ b/docs/ABNAHME.md @@ -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.** diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md new file mode 100644 index 0000000..8bc4169 --- /dev/null +++ b/docs/ANLEITUNG.md @@ -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. diff --git a/docs/bilder/03-verwaltung.png b/docs/bilder/03-verwaltung.png index 40b7d3c..7bac835 100644 Binary files a/docs/bilder/03-verwaltung.png and b/docs/bilder/03-verwaltung.png differ diff --git a/docs/bilder/06-auswertung.png b/docs/bilder/06-auswertung.png index c3abd0e..2851a33 100644 Binary files a/docs/bilder/06-auswertung.png and b/docs/bilder/06-auswertung.png differ diff --git a/docs/bilder/13-handout-n3.png b/docs/bilder/13-handout-n3.png index e4b123a..5281eaf 100644 Binary files a/docs/bilder/13-handout-n3.png and b/docs/bilder/13-handout-n3.png differ diff --git a/docs/bilder/14-merkkarten.png b/docs/bilder/14-merkkarten.png index 9298653..08bef42 100644 Binary files a/docs/bilder/14-merkkarten.png and b/docs/bilder/14-merkkarten.png differ diff --git a/docs/bilder/15-glossar.png b/docs/bilder/15-glossar.png index c46db7f..d74b014 100644 Binary files a/docs/bilder/15-glossar.png and b/docs/bilder/15-glossar.png differ diff --git a/docs/bilder/20-dunkel.png b/docs/bilder/20-dunkel.png index 5193c5e..cc12505 100644 Binary files a/docs/bilder/20-dunkel.png and b/docs/bilder/20-dunkel.png differ diff --git a/docs/bilder/22-wochenblatt.png b/docs/bilder/22-wochenblatt.png index 269c311..1370a90 100644 Binary files a/docs/bilder/22-wochenblatt.png and b/docs/bilder/22-wochenblatt.png differ diff --git a/docs/bilder/30-handy-handout.png b/docs/bilder/30-handy-handout.png index 1b1681c..aa404b6 100644 Binary files a/docs/bilder/30-handy-handout.png and b/docs/bilder/30-handy-handout.png differ diff --git a/docs/bilder/31-handy-lernmodus-frage.png b/docs/bilder/31-handy-lernmodus-frage.png index 19e2a3c..f0dba06 100644 Binary files a/docs/bilder/31-handy-lernmodus-frage.png and b/docs/bilder/31-handy-lernmodus-frage.png differ diff --git a/docs/bilder/32-handy-lernmodus-antwort.png b/docs/bilder/32-handy-lernmodus-antwort.png index 8616e73..4dd2977 100644 Binary files a/docs/bilder/32-handy-lernmodus-antwort.png and b/docs/bilder/32-handy-lernmodus-antwort.png differ diff --git a/package.json b/package.json index cdbe017..79f3f5d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/im-container/sicherung.sh b/scripts/im-container/sicherung.sh index 3756666..9f9c753 100755 --- a/scripts/im-container/sicherung.sh +++ b/scripts/im-container/sicherung.sh @@ -46,7 +46,12 @@ Inhalt: lernbar.sqlite (Datenbank), anhaenge/ (hochgeladene Dateien) Zurückspielen: wiederherstellen.sh 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" diff --git a/scripts/im-container/wiederherstellen.sh b/scripts/im-container/wiederherstellen.sh index a2de68a..27100b5 100755 --- a/scripts/im-container/wiederherstellen.sh +++ b/scripts/im-container/wiederherstellen.sh @@ -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 diff --git a/scripts/pruefungen/abnahme.mjs b/scripts/pruefungen/abnahme.mjs new file mode 100644 index 0000000..837dcd7 --- /dev/null +++ b/scripts/pruefungen/abnahme.mjs @@ -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);