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>
@@ -26,6 +26,54 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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
|
## [0.9.0] – 2026-09-11
|
||||||
|
|
||||||
Auswertung, Rahmenplanabdeckung und das Feedbackgespräch.
|
Auswertung, Rahmenplanabdeckung und das Feedbackgespräch.
|
||||||
@@ -401,6 +449,7 @@ anmelden und verwalten.
|
|||||||
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
||||||
|
|
||||||
[Unveröffentlicht]: #unveröffentlicht
|
[Unveröffentlicht]: #unveröffentlicht
|
||||||
|
[1.0.0]: #100--2026-09-11
|
||||||
[0.9.0]: #090--2026-09-11
|
[0.9.0]: #090--2026-09-11
|
||||||
[0.8.0]: #080--2026-09-11
|
[0.8.0]: #080--2026-09-11
|
||||||
[0.7.0]: #070--2026-09-10
|
[0.7.0]: #070--2026-09-10
|
||||||
|
|||||||
@@ -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
|
## 2026-09-11 · Baustein 9 – Auswertung und Feedbackgespräch
|
||||||
|
|
||||||
| Von | Bis | Dauer | Tätigkeit |
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|||||||
@@ -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
|
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
|
Gespräch unveränderlich. Solange nur eine Seite bestätigt hat, lässt sich die
|
||||||
Bestätigung zurücknehmen.
|
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.
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
|
|||||||
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
|
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
|
||||||
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
|
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
|
||||||
|
|
||||||
> **Stand:** Bausteine 1 bis 9 von 10 sind fertig. Es fehlt nur noch der
|
> **Stand:** Alle zehn Bausteine sind fertig – Fassung 1.0.0.
|
||||||
> Feinschliff: PWA, Offlinebetrieb, Druckansichten und die Anleitung mit
|
>
|
||||||
> Screenshots. Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das
|
> - [**Anleitung mit Bildschirmfotos**](docs/ANLEITUNG.md) – wie man damit arbeitet
|
||||||
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
|
> - [Abnahmeprotokoll](docs/ABNAHME.md) – die Abnahmekriterien, einzeln durchgespielt
|
||||||
|
> - [Änderungsprotokoll](CHANGELOG.md) · [Fahrplan](ROADMAP.md) ·
|
||||||
|
> [Entscheidungen](ENTSCHEIDUNGEN.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,10 +242,44 @@ Protokollzeilen.
|
|||||||
```bash
|
```bash
|
||||||
npm run typecheck # TypeScript
|
npm run typecheck # TypeScript
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm run test # Vitest – Logik, die wehtut
|
npm run test # Vitest – Logik, die wehtut (315 Tests)
|
||||||
npm run e2e # Playwright – der Hauptweg von Anfang bis Ende
|
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
|
## Aufbau
|
||||||
|
|||||||
@@ -105,11 +105,17 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
|||||||
|
|
||||||
## 1.0 – Abrundung
|
## 1.0 – Abrundung
|
||||||
|
|
||||||
- [ ] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
|
- [x] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
|
||||||
- [ ] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
|
- [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`
|
über `npm run screenshots`
|
||||||
- [ ] Ende-zu-Ende-Test über den Hauptweg
|
- [x] Ende-zu-Ende-Test über den Hauptweg (`scripts/pruefungen/abnahme.mjs`)
|
||||||
- [ ] Abnahmekriterien einzeln durchgespielt und protokolliert
|
- [x] Abnahmekriterien einzeln durchgespielt und protokolliert
|
||||||
|
(`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.**
|
||||||
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Ein Azubikonto anlegen
|
||||||
|
|
||||||
|
**Verwaltung → Neues Azubikonto**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 %).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> **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**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Handouts sind der Lesestoff: kurz, mit Beispielen, jeweils einem Thema
|
||||||
|
zugeordnet. Die Suche geht über Titel und Volltext.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Merkkarten lernen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 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 **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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|---|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 103 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lernbar",
|
"name": "lernbar",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
"backup": "bash scripts/im-container/sicherung.sh",
|
"backup": "bash scripts/im-container/sicherung.sh",
|
||||||
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
"restore": "bash scripts/im-container/wiederherstellen.sh",
|
||||||
"screenshots": "tsx scripts/screenshots.ts",
|
"screenshots": "tsx scripts/screenshots.ts",
|
||||||
"symbole": "tsx scripts/symbole.ts"
|
"symbole": "tsx scripts/symbole.ts",
|
||||||
|
"abnahme": "node scripts/pruefungen/abnahme.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ Inhalt: lernbar.sqlite (Datenbank), anhaenge/ (hochgeladene Dateien)
|
|||||||
Zurückspielen: wiederherstellen.sh <diese-datei.tar.gz>
|
Zurückspielen: wiederherstellen.sh <diese-datei.tar.gz>
|
||||||
ENDE
|
ENDE
|
||||||
|
|
||||||
ARCHIV="$ZIEL/lernbar-$STEMPEL.tar.gz"
|
# Bewusst erst ins Zielverzeichnis wechseln und dann einen blossen Dateinamen
|
||||||
tar -czf "$ARCHIV" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt
|
# 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"
|
echo "$ARCHIV"
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ fi
|
|||||||
ARBEIT="$(mktemp -d)"
|
ARBEIT="$(mktemp -d)"
|
||||||
trap 'rm -rf "$ARBEIT"' EXIT
|
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
|
if [ ! -f "$ARBEIT/lernbar.sqlite" ]; then
|
||||||
echo "Das Archiv enthält keine lernbar.sqlite – falsche Datei?" >&2
|
echo "Das Archiv enthält keine lernbar.sqlite – falsche Datei?" >&2
|
||||||
|
|||||||
@@ -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);
|
||||||