Baustein 2: Inhalte einlesen und pruefen, Handouts, Suche, Glossar

Die fachlichen Inhalte liegen als Dateien unter content/ und werden beim
Start eingelesen, geprueft und in den Speicher geladen.

- Inhaltslader mit Zod-Schemata und Querverweispruefung
- Fehlermeldungen nennen Datei und Zeile, gesammelt statt beim ersten
  Fehler geworfen; die Anwendung startet bei Fehlern nicht
- npm run inhalte:pruefen prueft ohne Start
- Handout-Ansicht mit festem Aufbau, Uebersicht nach Themenbloecken
- Volltextsuche mit Gewichtung und Trefferausschnitt
- Gelesen-Markierung je Handout
- Glossar mit Suche und automatischer Verlinkung in den Handouts
- Beispielinhalte: 4 Wochen, 12 Handouts, 24 Merkkarten, 4 Aufgaben,
  4 Checklisten, 2 Lernzielkontrollen, 24 Glossareintraege
- rahmenplan.yaml aus FIAusbV 2020 und KMK-Rahmenlehrplan, mit Fundstellen
- gray-matter entfernt, YAML-Kopf wird selbst herausgeloest
- npm run screenshots nimmt die Bilder fuer die Anleitung auf
- 39 neue Tests, insgesamt 68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 07:56:38 +02:00
co-authored by Claude Opus 5
parent e856349cd4
commit 1af5613e9b
70 changed files with 4779 additions and 124 deletions
+48
View File
@@ -26,6 +26,53 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.2.0] – 2026-09-10
Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien
im Repository, beim Start geprüft.
### Hinzugefügt
- Inhaltslader für `content/`: YAML und Markdown mit YAML-Kopf, Prüfung über
Zod-Schemata, Aufbau aller Querverweise
- **Fehlermeldungen mit Datei und Zeile.** Fehler werden gesammelt statt beim
ersten geworfen – wer zehn Tippfehler eingebaut hat, sieht sie in einem
Durchgang. Die Zeilennummer stammt aus dem YAML-Dokumentmodell, nicht aus
einer Schätzung
- Geprüft wird unter anderem: Existenz jeder referenzierten Kennung, Zugehörigkeit
jedes Handouts zu genau einer Woche (in beide Richtungen), Kürzel passend zum
Themenblock, Berufsbildpositionen und Lernfelder gegen `rahmenplan.yaml`,
lückenlose Wochennummerierung, doppelte Kennungen, Glossarverweise ins Leere
- `npm run inhalte:pruefen` prüft ohne Start und meldet zusätzlich, welche
Berufsbildpositionen bisher von keinem Inhalt berührt werden
- Handout-Ansicht mit dem festen Aufbau: Kürzel, Titel, „Worum es geht",
Das Wesentliche, Übersicht, hervorgehobener Merksatz, eingeklappte
Prüfungsfrage; dazu die Zuordnung zu Rahmenplan, Lernfeld und Woche
- Handout-Übersicht nach Themenblöcken, Filter je Block, Lesestand je Block
- Volltextsuche über Titel, Kurzbeschreibung, Merksatz, Merkkarten und
Fließtext – mit Gewichtung und Trefferausschnitt, unempfindlich gegen
Umlaute und Großschreibung
- „Als gelesen markieren" je Handout, gespeichert je Benutzer
- Glossar mit Suche; Begriffe werden in den Handouts automatisch verlinkt –
höchstens einmal je Seite, nie in Code, Überschriften oder bestehenden
Verweisen. Der Verweis ist ein echter Link mit Erklärung im title-Attribut,
damit er auch am Handy und mit der Tastatur nutzbar ist
- Neu einlesen bei Dateiänderung im Entwicklungsbetrieb; schlägt das fehl,
bleibt der bisherige Stand aktiv und der Fehler steht im Protokoll
- Beispielinhalte: 4 Wochen, 12 Handouts mit 24 Merkkarten, 4 Praxisaufgaben,
4 Checklisten, 2 Lernzielkontrollen mit Musterlösungen, 24 Glossareinträge
- `content/rahmenplan.yaml` mit allen Berufsbildpositionen der FIAusbV 2020
(Abschnitte A, C und F, hier als A1–A10, B1–B3, C1–C5) samt amtlicher
Fundstelle sowie allen zwölf Lernfeldern des KMK-Rahmenlehrplans
- `npm run screenshots` nimmt die Bilder für die Anleitung automatisiert auf
### Geändert
- `gray-matter` entfernt: Der YAML-Kopf wird selbst herausgelöst. Die Bibliothek
merkt sich Ergebnisse und lieferte beim zweiten Einlesen desselben Textes ein
anderes Objekt; außerdem stimmt der Zeilenversatz für Fehlermeldungen jetzt
garantiert
## [0.1.0] – 2026-09-10 ## [0.1.0] – 2026-09-10
Erster Baustein: das Fundament steht, die Anwendung lässt sich starten, Erster Baustein: das Fundament steht, die Anwendung lässt sich starten,
@@ -58,4 +105,5 @@ 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
[0.2.0]: #020--2026-09-10
[0.1.0]: #010--2026-09-10 [0.1.0]: #010--2026-09-10
+34
View File
@@ -7,6 +7,40 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
--- ---
## 2026-09-10 · Baustein 2 – Inhalte
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 07:05 | 07:15 | 0:10 | Remote eingetragen, Push scheitert an fehlenden Zugangsdaten – zurückgestellt, Arbeit läuft weiter |
| 07:15 | 07:25 | 0:10 | Ausbildungsrahmenplan und Lernfelder aus den Quellen geholt statt geraten (FIAusbV Anlage, KMK-Rahmenlehrplan) |
| 07:25 | 07:50 | 0:25 | Beispielinhalte geschrieben: 12 Handouts mit Merkkarten, 4 Wochenpläne, 4 Aufgaben, 4 Checklisten, 2 Lernzielkontrollen, 24 Glossareinträge |
| 07:50 | 08:20 | 0:30 | Inhaltslader: YAML-Dokumentmodell für Zeilennummern, Zod-Schemata, Fehlersammler, Querverweisprüfung |
| 08:20 | 08:35 | 0:15 | Handout-Ansicht, Übersicht nach Blöcken, Volltextsuche, Glossar mit automatischer Verlinkung |
| 08:35 | 08:50 | 0:15 | 39 neue Tests; zwei echte Fehler dabei gefunden (siehe unten) |
| 08:50 | 09:00 | 0:10 | Screenshot-Skript, Bilder aufgenommen, Fahrplan und Änderungsprotokoll nachgezogen |
**Summe Baustein 2: rund 1 h 55 min**
### Was aufgefallen ist
- Die Zeilennummer in der Fehlermeldung war den Aufwand wert. Beim ersten Lauf
gegen die echten Inhalte kam sofort `content/glossar.yaml:8 – Expected string,
received number`: `formen: [169.254]` hatte YAML als Gleitkommazahl gelesen.
Ohne Zeilenangabe hätte ich das in 24 Einträgen gesucht.
- `gray-matter` liefert beim zweiten Einlesen desselben Textes ein Objekt ohne
das Feld `matter` – im Betrieb fällt das nie auf, im Test sofort. Den YAML-Kopf
selbst herauszulösen ist zehn Zeilen Arbeit und beseitigt gleich zwei Probleme.
- Zod-Schemata mit `.default()` haben verschiedene Ein- und Ausgabetypen. Mit
`z.ZodType<T>` erzwingt TypeScript Gleichheit und leitet den falschen Typ ab;
`<S extends z.ZodTypeAny>` mit `z.output<S>` ist die richtige Form.
- Der Auftrag nennt für den Rahmenplan `C1` bis `C4`. Die Verordnung führt in
Abschnitt F aber **fünf** integrativ zu vermittelnde Positionen. Ich habe alle
fünf aufgenommen – sonst fehlt in der Abdeckung ausgerechnet die Position, die
2020 neu dazugekommen ist („Vernetztes Zusammenarbeiten unter Nutzung digitaler
Medien"). Siehe ENTSCHEIDUNGEN.md, E10.
---
## 2026-09-10 · Baustein 1 – Fundament ## 2026-09-10 · Baustein 1 – Fundament
| Von | Bis | Dauer | Tätigkeit | | Von | Bis | Dauer | Tätigkeit |
+53
View File
@@ -82,3 +82,56 @@ Erstfassung entsteht deshalb vollständig auf `feat/lernbar-erstfassung`.
--- ---
<!-- Weitere Entscheidungen werden unten angefügt und fortlaufend nummeriert. --> <!-- Weitere Entscheidungen werden unten angefügt und fortlaufend nummeriert. -->
## E10 · Rahmenplan: A1–A10, B1–B3, C1–**C5**
Der Auftrag nennt die Berufsbildpositionen `A 1 bis A 10`, `B 1 bis B 3` und
`C 1 bis C 4`. Die Verordnung selbst gliedert anders: Sie deckt alle vier
Fachrichtungen ab und hat deshalb sechs Abschnitte. Übersetzt:
| Kürzel hier | Abschnitt der FIAusbV | Inhalt |
|---|---|---|
| A1 – A10 | Abschnitt A | fachrichtungsübergreifend |
| B1 – B3 | Abschnitt C | Fachrichtung Systemintegration |
| C1 – C5 | Abschnitt F | integrativ zu vermitteln |
Abweichung: Abschnitt F hat **fünf** Positionen, nicht vier. Die fünfte
(„Vernetztes Zusammenarbeiten unter Nutzung digitaler Medien") ist 2020 neu
dazugekommen. Sie wegzulassen würde ausgerechnet in der Abdeckungsansicht eine
Lücke erzeugen, die gegenüber der IHK zählt. Deshalb sind alle fünf drin.
Jede Position trägt im Feld `verordnung` ihre amtliche Fundstelle, damit die
Übersetzung nachvollziehbar bleibt.
## E11 · Kennung einer Merkkarte: Handout-Kürzel plus laufende Nummer
Eine Karte heißt `N3-1`, `N3-2` und so weiter – abgeleitet aus dem Handout und
der Reihenfolge im YAML-Kopf. Damit steht die Kennung fest, ohne dass sie in der
Datei gepflegt werden muss.
Der Preis: Wird eine Karte **mittendrin** eingefügt oder gelöscht, verschieben
sich die Kennungen der folgenden, und ihr Lernstand rutscht mit. Neue Karten
gehören deshalb ans Ende der Liste. Bei einem Handout mit ein bis drei Karten
ist das zumutbar; eine gepflegte Kennung je Karte wäre mehr Aufwand für die
Person, die Inhalte schreibt, als sie einbringt.
## E12 · Glossarverweise: Link statt Überfahren
Der Auftrag lässt die Wahl zwischen automatischer Verlinkung und einer
Erklärung beim Überfahren mit der Maus. Es wird beides, aber der Link führt:
Ein reiner `title`-Hinweis ist am Handy nicht erreichbar und mit der Tastatur
nur mit Mühe. Der Verweis zeigt auf die Stelle im Glossar und trägt die
Erklärung zusätzlich im `title`.
Zurückhaltung ist eingebaut: Jeder Begriff wird höchstens einmal je Seite
verlinkt, und nie innerhalb von Code, Überschriften, Tabellenköpfen oder
bestehenden Verweisen. Ein Text, in dem jedes zweite Wort unterstrichen ist,
liest sich schlechter als einer ganz ohne.
## E13 · Kein `gray-matter`
Der YAML-Kopf einer Markdown-Datei wird selbst herausgelöst (zehn Zeilen).
Gründe: `gray-matter` merkt sich Ergebnisse und liefert beim zweiten Einlesen
desselben Textes ein Objekt ohne das Feld `matter` – im Betrieb fällt das nie
auf, im Test sofort. Und der Zeilenversatz für die Fehlermeldungen ist so
nachweislich richtig statt nachgebaut.
+10 -6
View File
@@ -22,12 +22,16 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## 0.2 – Inhalte ## 0.2 – Inhalte
- [ ] **B2 · Inhalte einlesen und validieren, Handouts anzeigen, Suche** - [x] **B2 · Inhalte einlesen und validieren, Handouts anzeigen, Suche**
- [ ] Markdown mit YAML-Frontmatter, Zod-Prüfung, Start bricht bei Fehler ab - [x] Markdown mit YAML-Frontmatter, Zod-Prüfung, Start bricht bei Fehler ab
- [ ] Fehlermeldung nennt Datei und Zeile - [x] Fehlermeldung nennt Datei und Zeile
- [ ] Handout-Ansicht mit festem Aufbau, Themenblöcke, „gelesen" merken - [x] Handout-Ansicht mit festem Aufbau, Themenblöcke, „gelesen" merken
- [ ] Volltextsuche über alle Handouts - [x] Volltextsuche über alle Handouts
- [ ] Neu einlesen bei Dateiänderung im Entwicklungsbetrieb - [x] Neu einlesen bei Dateiänderung im Entwicklungsbetrieb
- [x] Glossar mit automatischer Verlinkung in den Handouts
- [x] Beispielinhalte: 4 Wochen, 12 Handouts, 24 Merkkarten, 4 Aufgaben,
4 Checklisten, 2 Lernzielkontrollen, 24 Glossareinträge
- [x] Rahmenplan aus FIAusbV und KMK-Rahmenlehrplan als Nachschlagewerk
## 0.3 – Woche und Wiederholung ## 0.3 – Woche und Wiederholung
@@ -0,0 +1,51 @@
---
id: "1.1"
titel: Einen Arbeitsplatz übernehmen und übergeben
woche: 1
block: arbeitsalltag
ziel: Du übernimmst einen frei gewordenen Arbeitsplatz, machst ihn für die Nachfolge einsatzbereit und dokumentierst den Vorgang so, dass er prüffähig ist.
zeitrahmen: 3 Stunden
rahmenplan: [A1, A7, C3]
lernfeld: LF1
material:
- Der frei gewordene Arbeitsplatzrechner samt Bildschirm, Tastatur und Maus
- Zugang zum Ticketsystem und zur Geräteliste
- Übergabeprotokoll (Vorlage beim Ausbilder)
- Externer Datenträger für die Sicherung
kriterien:
- Nutzerdaten wurden gesichert und die Sicherung stichprobenartig zurückgespielt
- Gerät ist in der Geräteliste mit Seriennummer, Standort und Zustand erfasst
- Alle personenbezogenen Daten des Vorgängers sind entfernt oder gesperrt
- Das Übergabeprotokoll ist vollständig und von beiden Seiten unterschrieben
- Der Vorgang ist als Ticket dokumentiert, Aufwand ist erfasst
---
## Auftrag
Ein Kollege hat den Betrieb verlassen. Sein Arbeitsplatz soll in zwei Wochen von einer neuen
Kollegin übernommen werden. Bis dahin ist das Gerät bei dir.
Deine Aufgabe:
1. Nimm das Gerät auf: Seriennummer, Modell, Alter, Zustand, verbaute Ausstattung.
Trage alles in die Geräteliste ein und ergänze fehlende Angaben.
2. Sichere die Nutzerdaten des Vorgängers auf den bereitgestellten Datenträger und **prüfe
die Sicherung**, indem du eine beliebige Datei an anderer Stelle zurückspielst.
Kläre mit dem Ausbilder, wie lange diese Sicherung aufbewahrt wird.
3. Prüfe den Zustand des Datenträgers (SMART) und dokumentiere das Ergebnis. Bewerte, ob das
Gerät weiterverwendet werden kann.
4. Entferne beziehungsweise sperre das Konto des Vorgängers. Beachte: sperren, nicht löschen.
5. Fülle das Übergabeprotokoll aus und lass es vom Ausbilder gegenzeichnen.
6. Dokumentiere den gesamten Vorgang als Ticket – mit Aufwandsschätzung vorher und
tatsächlichem Aufwand nachher.
## Worauf es ankommt
Der eigentliche Prüfstein dieser Aufgabe ist nicht der technische Teil, sondern die
Dokumentation. Nimm an, du wärst in der Woche der Übernahme krank: Könnte ein Kollege
allein anhand deiner Einträge weiterarbeiten, ohne dich anzurufen?
## Zeit
Rechne mit drei Stunden. Wenn du deutlich darunter oder darüber liegst, notiere im
Berichtsheft, woran es lag – das ist Teil der Übung aus Handout A3.
@@ -0,0 +1,48 @@
---
id: "2.1"
titel: Datenträger aus dem Lager beurteilen und aussortieren
woche: 2
block: hardware
ziel: Du beurteilst gebrauchte Datenträger anhand ihrer SMART-Werte, triffst eine begründete Entscheidung über Weiterverwendung und entsorgst die aussortierten datenschutzgerecht.
zeitrahmen: 2 Stunden
rahmenplan: [A3, A9, C4]
lernfeld: LF2
material:
- Fünf gebrauchte Datenträger aus dem Lager
- USB-Adapter für SATA und M.2
- Auslesewerkzeug für SMART-Werte
- Vernichtungsprotokoll des Betriebs
kriterien:
- Für jeden Datenträger sind Modell, Seriennummer, Betriebsstunden und die kritischen SMART-Werte erfasst
- Die Entscheidung Weiterverwendung oder Aussonderung ist je Datenträger begründet
- Datenträger mit Fehlern über null sind ausgesondert, nicht "unter Beobachtung"
- Aussonderungen sind im Vernichtungsprotokoll erfasst
- Weiterverwendbare Datenträger sind sicher gelöscht und beschriftet
---
## Auftrag
Im Lager liegen fünf gebrauchte Datenträger ohne Kennzeichnung. Es soll entschieden werden,
welche davon noch in Testgeräte eingebaut werden können.
1. Lies zu jedem Datenträger Modell, Seriennummer, Betriebsstunden und die SMART-Werte aus.
Halte insbesondere Reallocated Sector Count, Current Pending Sector und – bei SSDs –
Percentage Used fest.
2. Entscheide je Datenträger: Weiterverwendung oder Aussonderung. **Begründe jede
Entscheidung in einem Satz.** „Sieht in Ordnung aus" ist keine Begründung.
3. Lösche die weiterverwendbaren Datenträger sicher und beschrifte sie mit Datum und
deinem Kürzel.
4. Trage die ausgesonderten Datenträger ins Vernichtungsprotokoll ein und übergib sie dem
Ausbilder. Kläre dabei, wie im Betrieb datenschutzgerecht entsorgt wird und warum ein
einfaches Formatieren dafür nicht ausreicht.
## Worauf es ankommt
Die häufigste Fehlentscheidung an dieser Stelle lautet „ein paar defekte Sektoren sind
normal, den beobachten wir". Werden sie nicht. Ein Datenträger mit wachsenden Fehlern
kostet später mehr Arbeitszeit, als er an Anschaffung spart – und im schlechten Fall Daten.
## Zeit
Zwei Stunden, davon etwa die Hälfte für das sichere Löschen. Das läuft nebenher, plane es
aber ein.
@@ -0,0 +1,48 @@
---
id: "2.2"
titel: Drei Notebooks für den Außendienst vergleichen
woche: 2
block: hardware
ziel: Du erstellst eine nachvollziehbare Beschaffungsempfehlung für drei Notebooks anhand gewichteter Kriterien und begründest sie gegenüber dem Ausbilder.
zeitrahmen: 4 Stunden
rahmenplan: [A3, A2, A7]
lernfeld: LF2
material:
- Anforderungsbeschreibung des Außendienstes (beim Ausbilder)
- Zugang zu den Katalogen der beiden Hauslieferanten
- Tabellenkalkulation für die Nutzwertanalyse
kriterien:
- Der Einsatzzweck ist in eigenen Worten beschrieben, bevor Geräte gesucht wurden
- Mindestens fünf Kriterien sind festgelegt und begründet gewichtet
- Die Gewichtung wurde vor der Gerätesuche festgelegt und nicht nachträglich geändert
- Drei Geräte sind vollständig bewertet, Punktvergabe je Kriterium ist nachvollziehbar
- Gesamtkosten über vier Jahre sind berücksichtigt, nicht nur der Anschaffungspreis
- Die Empfehlung ist schriftlich und umfasst höchstens eine Seite
---
## Auftrag
Der Außendienst braucht sechs neue Notebooks. Die Kollegen sind ganztägig unterwegs, oft
ohne Steckdose, gelegentlich auf Baustellen.
1. Schreibe zuerst den Einsatzzweck in eigenen Worten auf – **bevor** du dir ein Gerät
ansiehst. Kläre offene Punkte mit dem Ausbilder.
2. Leite daraus mindestens fünf Kriterien ab und gewichte sie. Lass die Gewichtung vom
Ausbilder abzeichnen, bevor du weitermachst.
3. Suche drei Geräte, die grundsätzlich in Frage kommen, und bewerte sie je Kriterium mit
1 bis 10 Punkten.
4. Rechne die Gesamtkosten über vier Jahre: Anschaffung, Garantieverlängerung, Zubehör,
absehbare Ersatzteile.
5. Schreibe eine Empfehlung von höchstens einer Seite: welches Gerät, warum, und was gegen
die beiden anderen spricht.
## Worauf es ankommt
Die Empfehlung muss auch dann tragen, wenn jemand die Gewichte anders setzen würde. Schreib
deshalb dazu, wie empfindlich das Ergebnis auf die Gewichtung reagiert – wenn ein Gerät
schon bei kleiner Verschiebung vorne läge, gehört das erwähnt.
## Zeit
Vier Stunden. Der größte Teil geht für Recherche drauf; die Analyse selbst ist in einer
Stunde gemacht, wenn die Kriterien stehen.
@@ -0,0 +1,48 @@
---
id: "3.1"
titel: Vier gestörte Clients ins Netz bringen
woche: 3
block: netzwerk
ziel: Du grenzt vier unterschiedlich gestörte Arbeitsplätze schichtweise ein, bringst sie ins Netz und dokumentierst je Fall Ursache, Weg und Nachweis.
zeitrahmen: 3 Stunden
rahmenplan: [B2, A8, A1]
lernfeld: LF3
material:
- Vier vorbereitete Testrechner im Schulungsnetz
- Netzplan des Schulungsnetzes
- Kabeltester
- Zugang zum DHCP- und DNS-Server (lesend)
kriterien:
- Für jeden Fall ist die geprüfte Schicht und das verwendete Werkzeug dokumentiert
- Die tatsächliche Ursache ist benannt, nicht nur die Maßnahme
- Die Fehlersuche verlief von unten nach oben, Abweichungen sind begründet
- Alle vier Clients erreichen am Ende den Dateiserver über den Namen
- Je Fall liegt ein Nachweis vor (Bildschirmfoto oder abgetippte Ausgabe)
---
## Auftrag
Im Schulungsnetz stehen vier Rechner, die alle „nicht ins Netz kommen". Die Ursachen sind
unterschiedlich. Einer davon hat mehr als einen Fehler.
Für jeden Rechner:
1. Nimm das Fehlerbild auf, **bevor** du etwas veränderst. Was genau geht nicht?
2. Arbeite dich von unten nach oben durch: Leitung und Verbindungsanzeige, Adresse und
Maske, Gateway, Port, Namensauflösung.
3. Halte fest, welches Werkzeug du auf welcher Schicht eingesetzt hast und was es ausgegeben
hat. Ein Bildschirmfoto oder die abgetippte Ausgabe reicht.
4. Benenne die Ursache. „Habe die IP neu geholt" ist die Maßnahme, nicht die Ursache.
5. Weise nach, dass der Fehler behoben ist: Zugriff auf den Dateiserver **über den Namen**,
nicht über die IP-Adresse.
## Worauf es ankommt
Der Rechner mit zwei Fehlern ist der eigentliche Prüfstein. Wer nach dem ersten Erfolg
aufhört zu prüfen, meldet ihn als erledigt und bekommt ihn morgen zurück. Prüfe deshalb bei
jedem Rechner am Ende den vollständigen Weg, auch wenn du glaubst, fertig zu sein.
## Zeit
Drei Stunden für alle vier. Wenn du an einem Rechner länger als 45 Minuten hängst, hol den
Ausbilder dazu – festbeißen gehört nicht zur Übung.
@@ -0,0 +1,60 @@
id: 01-arbeitsplatz-uebergeben
titel: Arbeitsplatz übernehmen und übergeben
block: arbeitsalltag
worum: >-
Ein Gerät wechselt den Besitzer. Diese Liste stellt sicher, dass weder Daten
noch Zuständigkeiten dabei verloren gehen.
rahmenplan: [A1, A7, A6]
lernfeld: LF1
mit_ticket: true
gruppen:
- id: vorbereiten
titel: Vorbereiten
schritte:
- id: v1
text: Auftrag geklärt – wer gibt ab, wer übernimmt, bis wann?
- id: v2
text: Gerät in der Geräteliste gefunden oder neu erfasst (Seriennummer, Modell, Standort)
- id: v3
text: Externen Datenträger für die Sicherung bereitgelegt und freien Platz geprüft
- id: v4
text: Mit dem Vorgänger geklärt, welche Daten persönlich und welche dienstlich sind
- id: durchfuehren
titel: Durchführen
schritte:
- id: d1
text: Nutzerdaten gesichert
- id: d2
text: Sicherung stichprobenartig zurückgespielt und geöffnet
- id: d3
text: Lizenzen und installierte Fachanwendungen notiert
- id: d4
text: Konto des Vorgängers gesperrt (nicht gelöscht)
- id: d5
text: Persönliche Daten vom Gerät entfernt
- id: d6
text: Gerät gereinigt, Kabel und Zubehör auf Vollständigkeit geprüft
- id: pruefen
titel: Prüfen
schritte:
- id: p1
text: SMART-Werte des Datenträgers gelesen und bewertet
- id: p2
text: Gerät startet, meldet sich am Netz an, Fachanwendungen starten
- id: p3
text: Keine Zugriffe des Vorgängers mehr möglich (mit Testkonto geprüft)
- id: abschliessen
titel: Abschließen
schritte:
- id: a1
text: Übergabeprotokoll ausgefüllt und von beiden Seiten unterschrieben
- id: a2
text: Geräteliste aktualisiert (neuer Nutzer, Datum)
- id: a3
text: Aufbewahrungsfrist der Sicherung notiert und Wiedervorlage gesetzt
- id: a4
text: Ticket geschlossen – mit Ursache, Maßnahme und tatsächlichem Aufwand
@@ -0,0 +1,75 @@
id: 02-windows-installieren
titel: Windows 11 installieren und übergeben
block: system
worum: >-
Von der gesicherten Ausgangslage bis zum unterschriebenen Übergabeprotokoll.
Die Liste ist bewusst länger, als es sich anfühlt – genau die Schritte am Ende
sind es, die sonst vergessen werden.
rahmenplan: [B1, B3, A5]
lernfeld: LF2
mit_ticket: true
gruppen:
- id: vorbereiten
titel: Vorbereiten
schritte:
- id: v1
text: Nutzerdaten gesichert und Rückspielen einer Beispieldatei getestet
- id: v2
text: Lizenz geklärt (Datenträger, Volumenlizenz, im Gerät hinterlegt?)
- id: v3
text: Installationsmedium auf aktuellem Stand
- id: v4
text: Treiberpaket des Herstellers heruntergeladen (Chipsatz, Netzwerk, Grafik)
- id: v5
text: Liste der benötigten Fachanwendungen erstellt und Zugangsdaten geklärt
- id: v6
text: Rechnername und Netzwerkdaten festgelegt
- id: durchfuehren
titel: Durchführen
schritte:
- id: d1
text: Von Installationsmedium gestartet, Datenträger partitioniert
- id: d2
text: Windows installiert, lokales Administratorkonto angelegt
- id: d3
text: Chipsatztreiber zuerst, danach die übrigen Treiber eingespielt
- id: d4
text: Alle Aktualisierungen eingespielt, bis "Sie sind auf dem neuesten Stand" erscheint
- id: d5
text: Gerät in die Domäne aufgenommen
- id: d6
text: Fachanwendungen installiert und einmal gestartet
- id: d7
text: Datensicherung des Arbeitsplatzes eingerichtet
- id: pruefen
titel: Prüfen
schritte:
- id: p1
text: Geräte-Manager ohne unbekannte Geräte
- id: p2
text: Anmeldung mit dem echten Nutzerkonto erfolgreich
- id: p3
text: Netzlaufwerke verbunden, Drucker erreichbar, Testdruck erfolgt
- id: p4
text: Fachanwendungen mit Nutzerkonto angemeldet getestet
- id: p5
text: Datensicherung einmal ausgeführt und Ergebnis kontrolliert
- id: p6
text: Nutzerdaten aus der Sicherung zurückgespielt und auf Vollständigkeit geprüft
- id: abschliessen
titel: Abschließen
schritte:
- id: a1
text: Rechnername, Seriennummer, Lizenz und Datum dokumentiert
- id: a2
text: Anwender eingewiesen, Anmeldung gemeinsam durchgeführt
- id: a3
text: Übergabeprotokoll unterschrieben
- id: a4
text: Altgerät beziehungsweise alter Datenträger nach Vorgabe behandelt
- id: a5
text: Ticket geschlossen mit Ursache, Maßnahme und tatsächlichem Aufwand
@@ -0,0 +1,58 @@
id: 03-client-ins-netz-bringen
titel: Client ins Netz bringen
block: netzwerk
worum: >-
Die Reihenfolge ist der eigentliche Inhalt dieser Liste: von unten nach oben.
Wer bei DNS anfängt, sucht am längsten.
rahmenplan: [B2, A8]
lernfeld: LF3
mit_ticket: true
gruppen:
- id: vorbereiten
titel: Vorbereiten
schritte:
- id: v1
text: Fehlerbild aufgenommen, bevor etwas verändert wurde
- id: v2
text: Geklärt, ob nur dieser Arbeitsplatz betroffen ist oder mehrere
- id: v3
text: Netzplan und Anschlussdokumentation zur Hand
- id: durchfuehren
titel: Durchführen
schritte:
- id: d1
text: Schicht 1 – Kabel gesteckt, Verbindungsanzeige an Gerät und Anschlussdose leuchtet
- id: d2
text: Schicht 2 – Anschluss am Switch aktiv, richtiges VLAN zugewiesen
- id: d3
text: Schicht 3 – gültige IP-Adresse vorhanden (keine 169.254.x.x), Maske und Gateway geprüft
- id: d4
text: Gateway angepingt
- id: d5
text: Schicht 4 – benötigte Ports zum Zielsystem erreichbar
- id: d6
text: Schicht 7 – Namensauflösung geprüft, Zwischenspeicher geleert
- id: pruefen
titel: Prüfen
schritte:
- id: p1
text: Dateiserver über den Namen erreichbar, nicht nur über die IP-Adresse
- id: p2
text: Drucker erreichbar, Testdruck erfolgt
- id: p3
text: Nach einem Neustart weiterhin erreichbar
- id: abschliessen
titel: Abschließen
schritte:
- id: a1
text: Ursache benannt, nicht nur die Maßnahme
- id: a2
text: Anschluss- und Netzdokumentation aktualisiert, falls etwas nicht stimmte
- id: a3
text: Anwender informiert, dass er wieder arbeiten kann
- id: a4
text: Ticket geschlossen mit Ursache, Maßnahme und tatsächlichem Aufwand
@@ -0,0 +1,64 @@
id: 04-rechner-zerlegen
titel: Rechner zerlegen und wieder aufbauen
block: hardware
worum: >-
Übung und Wartungsvorgang zugleich. Der wichtigste Teil steht unter
"Vorbereiten" – ein statischer Schlag zerstört nicht laut, sondern leise.
rahmenplan: [A3, B1, C3]
lernfeld: LF2
mit_ticket: false
gruppen:
- id: vorbereiten
titel: Vorbereiten
schritte:
- id: v1
text: Gerät heruntergefahren, Netzstecker gezogen
- id: v2
text: Einschaltknopf einige Sekunden gedrückt, um Reststrom abzubauen
- id: v3
text: Antistatikband angelegt oder Gehäuse geerdet berührt
- id: v4
text: Arbeitsfläche freigeräumt, Schälchen für Schrauben bereit
- id: v5
text: Ausgangszustand fotografiert (Kabelführung, Steckplätze)
- id: durchfuehren
titel: Durchführen
schritte:
- id: d1
text: Seitenwand geöffnet, Innenraum entstaubt (Druckluft, nicht Staubsauger)
- id: d2
text: Erweiterungskarten entnommen und beschriftet abgelegt
- id: d3
text: Datenträger und Kabel entnommen, Anschlüsse notiert
- id: d4
text: Arbeitsspeicher entnommen, Bänke notiert
- id: d5
text: CPU-Kühler abgenommen, Wärmeleitpaste beurteilt
- id: d6
text: Netzteil ausgebaut und auf Auffälligkeiten geprüft
- id: d7
text: Alles in umgekehrter Reihenfolge wieder eingebaut
- id: pruefen
titel: Prüfen
schritte:
- id: p1
text: Sichtprüfung – keine losen Kabel, keine übrig gebliebenen Schrauben
- id: p2
text: Gerät startet, Firmware erkennt alle Datenträger und den vollen Arbeitsspeicher
- id: p3
text: Arbeitsspeicher läuft im Zweikanalbetrieb
- id: p4
text: Temperaturen unter Last im Rahmen, Lüfter drehen hoch
- id: abschliessen
titel: Abschließen
schritte:
- id: a1
text: Getauschte Teile mit Datum und Grund dokumentiert
- id: a2
text: Arbeitsplatz aufgeräumt, Altteile nach Vorgabe entsorgt
- id: a3
text: Auffälligkeiten dem Ausbilder gemeldet
+184
View File
@@ -0,0 +1,184 @@
# Glossar – Begriffe in verständlichem Deutsch erklärt.
# Begriffe, die in Handouts vorkommen, werden dort automatisch verlinkt.
# `formen` nennt weitere Schreibweisen, unter denen der Begriff im Text erkannt
# werden soll (Plural, Abkürzung, ausgeschriebene Fassung).
- begriff: APIPA
steht_fuer: Automatic Private IP Addressing
formen: ["169.254"]
erklaerung: >-
Die Notlösung eines Rechners, wenn kein DHCP-Server antwortet: Er gibt sich
selbst eine Adresse aus dem Bereich 169.254.x.x. Damit erreicht er nur andere
Geräte, die dasselbe getan haben. Praktisch ist es keine Adresse, sondern
eine Fehlermeldung mit vier Zahlen.
siehe_auch: [DHCP]
- begriff: Blockgröße
erklaerung: >-
Beim Subnetting die Anzahl der Adressen, die ein Netz umfasst – einschließlich
Netz- und Broadcastadresse. Sie ergibt sich aus 256 minus dem Maskenwert im
betreffenden Oktett und ist der Schlüssel zum Rechnen im Kopf.
siehe_auch: [Subnetzmaske, CIDR]
- begriff: Broadcast
erklaerung: >-
Eine Nachricht an alle Geräte im selben Netz. Die Broadcastadresse ist die
höchste Adresse eines Netzes und kann keinem Gerät zugewiesen werden.
siehe_auch: [Netzadresse]
- begriff: CIDR
steht_fuer: Classless Inter-Domain Routing
formen: [Präfixschreibweise]
erklaerung: >-
Die Schreibweise mit Schrägstrich, etwa 192.168.10.0/24. Die Zahl nach dem
Schrägstrich sagt, wie viele Bits von links das Netz bezeichnen. Kürzer und
weniger fehleranfällig als die Maske in vier Zahlen.
siehe_auch: [Subnetzmaske]
- begriff: DHCP
steht_fuer: Dynamic Host Configuration Protocol
erklaerung: >-
Der Dienst, der Geräten beim Einschalten eine IP-Adresse zuteilt – dazu
Maske, Gateway und DNS-Server. Die Adresse gilt auf Zeit und wird
selbstständig verlängert. Ablauf in vier Schritten: Discover, Offer, Request,
Acknowledge.
siehe_auch: [APIPA, DNS, Lease]
- begriff: DNS
steht_fuer: Domain Name System
erklaerung: >-
Übersetzt Namen in IP-Adressen. Wenn eine IP-Adresse erreichbar ist, der Name
aber nicht, liegt der Fehler hier. Gegenprobe mit nslookup.
siehe_auch: [DHCP, TTL]
- begriff: Gateway
formen: [Standardgateway]
erklaerung: >-
Der Ausgang aus dem eigenen Netz. Alles, was nicht im selben Netz liegt,
schickt ein Rechner an diese Adresse. Liegt das Gateway selbst nicht im
eigenen Netz, kommt der Rechner nirgendwo hin.
siehe_auch: [Subnetzmaske, Router]
- begriff: Lease
formen: [Mietdauer]
erklaerung: >-
Die Zeit, für die ein per DHCP vergebene Adresse gilt. Nach der Hälfte der
Zeit fragt der Client von sich aus nach Verlängerung.
siehe_auch: [DHCP]
- begriff: MAC-Adresse
steht_fuer: Media Access Control
erklaerung: >-
Die werkseitig vergebene Kennung einer Netzwerkschnittstelle, sechs
Byte lang. Sie gilt nur im selben Netz und wird an jedem Router ersetzt.
Grundlage für DHCP-Reservierungen.
siehe_auch: [Switch]
- begriff: Netzadresse
erklaerung: >-
Die kleinste Adresse eines Netzes, bei der alle Hostbits null sind. Sie
bezeichnet das Netz selbst und wird keinem Gerät zugewiesen.
siehe_auch: [Broadcast, Subnetzmaske]
- begriff: NTFS-Berechtigung
erklaerung: >-
Die Zugriffsrechte, die direkt am Dateisystem hängen. Sie gelten lokal wie
über das Netz. Trifft sie auf eine Freigabeberechtigung, setzt sich die
jeweils strengere durch.
siehe_auch: [Freigabeberechtigung, Vererbung]
- begriff: Freigabeberechtigung
erklaerung: >-
Die Rechte, die für den Zugriff über das Netz auf eine Freigabe gelten. In
der Praxis wird sie großzügig gesetzt und die eigentliche Steuerung über
NTFS-Berechtigungen gemacht – sonst sucht man den Fehler an zwei Stellen.
siehe_auch: [NTFS-Berechtigung]
- begriff: Vererbung
erklaerung: >-
Berechtigungen eines Ordners gelten für alles darunter, solange die Vererbung
nicht unterbrochen wird. Jede Unterbrechung muss dokumentiert werden – sonst
versteht sie in zwei Jahren niemand mehr.
siehe_auch: [NTFS-Berechtigung]
- begriff: Prinzip der geringsten Rechte
formen: [Least Privilege]
erklaerung: >-
Jedes Konto bekommt genau die Rechte, die es für seine Aufgabe braucht, und
nicht mehr. Auf Zeit vergebene Rechte werden wieder entzogen. Der wirksamste
einzelne Schutz gegen Schadsoftware und Fehlbedienung.
siehe_auch: [NTFS-Berechtigung]
- begriff: Port
erklaerung: >-
Die Nummer, die auf einem Rechner den Dienst bezeichnet – 443 für HTTPS, 445
für Dateifreigaben. Ist eine IP-Adresse erreichbar, ein Dienst aber nicht,
ist meist ein Port gesperrt.
siehe_auch: [Firewall]
- begriff: Firewall
erklaerung: >-
Filtert Datenverkehr nach Regeln – nach Adresse, Port und je nach Bauart
auch nach Anwendung. Eine Firewall, deren Regeln niemand mehr versteht,
schützt weniger, als sie stört.
siehe_auch: [Port]
- begriff: Router
erklaerung: >-
Verbindet verschiedene Netze und entscheidet anhand der IP-Adresse, wohin ein
Paket weitergeht. Arbeitet auf Schicht 3.
siehe_auch: [Switch, Gateway]
- begriff: Switch
erklaerung: >-
Verteilt Datenpakete innerhalb eines Netzes anhand der MAC-Adresse. Arbeitet
auf Schicht 2. Viele Geräte können zusätzlich routen – bei der Fehlersuche
muss klar sein, in welcher Rolle das Gerät gerade arbeitet.
siehe_auch: [Router, MAC-Adresse, VLAN]
- begriff: VLAN
steht_fuer: Virtual Local Area Network
erklaerung: >-
Eine logische Aufteilung eines Switches in mehrere getrennte Netze. Ein
Arbeitsplatz im falschen VLAN verhält sich wie einer mit gezogenem Kabel –
er bekommt keine passende Adresse.
siehe_auch: [Switch]
- begriff: SMART
steht_fuer: Self-Monitoring, Analysis and Reporting Technology
erklaerung: >-
Die Selbstauskunft eines Datenträgers über seinen Zustand. Reallocated und
Pending Sectors über null bedeuten Tausch. Ein sauberer Wert ist keine
Garantie, ein auffälliger aber eine verlässliche Warnung.
siehe_auch: [Datenträger]
- begriff: Subnetzmaske
formen: [Maske, Netzmaske]
erklaerung: >-
Legt fest, welcher Teil einer IP-Adresse das Netz bezeichnet und welcher den
einzelnen Rechner. Sie beantwortet genau eine Frage: Kann ich mein Ziel
direkt erreichen oder muss ich über das Gateway?
siehe_auch: [CIDR, Gateway, Blockgröße]
- begriff: TTL
steht_fuer: Time to Live
erklaerung: >-
Die Gültigkeitsdauer eines DNS-Eintrags im Zwischenspeicher. Wer einen Server
umzieht, setzt die TTL vorher herunter – sonst zeigen Clients tagelang auf
die alte Adresse.
siehe_auch: [DNS]
- begriff: Ticket
erklaerung: >-
Der dokumentierte Vorgang zu einer Störung oder einem Auftrag. Es ist nicht
Verwaltungsaufwand neben der Arbeit, sondern die einzige Dokumentation der
Arbeit.
siehe_auch: []
- begriff: Datenträger
formen: [Festplatte, SSD]
erklaerung: >-
Der dauerhafte Speicher eines Rechners. Festplatten verschleißen mechanisch,
SSDs durch Schreibvorgänge – die Kennzahlen für den Zustand sind deshalb
unterschiedlich.
siehe_auch: [SMART]
+49
View File
@@ -0,0 +1,49 @@
---
id: A1
titel: Ein Ticket schreiben, das keine Rückfrage auslöst
block: arbeitsalltag
woche: 1
worum: Ein gutes Ticket beantwortet vorab die drei Fragen, die sonst als Rückfrage zurückkommen.
rahmenplan: [A1, A2]
lernfeld: LF6
merksatz: Wer, was, seit wann – und was schon versucht wurde. Alles andere ist Beiwerk.
karten:
- frage: Welche vier Angaben gehören in jedes Ticket, bevor du es abschickst?
antwort: Betroffene Person und Gerät, beobachtetes Verhalten im Wortlaut, Zeitpunkt des ersten Auftretens, und was bereits versucht wurde.
- frage: Warum ist „Drucker geht nicht" ein schlechter Betreff?
antwort: Er nennt weder Gerät noch Fehlerbild. Besser ist „Kyocera Flur 2 – Druckaufträge bleiben in der Warteschlange, seit heute 08:00".
---
## Das Wesentliche
- **Ein Ticket pro Problem.** Zwei Störungen in einem Ticket führen dazu, dass eine davon
beim Schließen untergeht.
- **Betreff wie eine Schlagzeile:** Gerät oder Dienst, Fehlerbild, Zeitpunkt. Der Betreff
muss für sich allein verständlich sein – in der Übersicht sieht man nichts anderes.
- **Fehlermeldungen im Wortlaut**, abgetippt oder als Bildschirmfoto, nicht sinngemäß.
„Irgendwas mit Netzwerk" kostet später eine halbe Stunde.
- **Was schon versucht wurde**, gehört hinein. Sonst macht es der Nächste noch einmal.
- **Erreichbarkeit und Dringlichkeit** notieren: Wann ist die Person am Platz? Blockiert die
Störung die Arbeit oder ist sie lästig?
- **Beim Schließen** schreibst du auf, was die Ursache war und was du getan hast – nicht
„erledigt". Das Ticket ist die Dokumentation, und zwar die einzige.
## Übersicht
| Feld | Schlecht | Gut |
|------|----------|-----|
| Betreff | Drucker geht nicht | Kyocera Flur 2 – Aufträge bleiben in Warteschlange, seit 08:00 |
| Beschreibung | Fehler beim Drucken | Meldung „Der Spooler-Dienst wird nicht ausgeführt", betrifft alle Nutzer in Halle 2 |
| Bereits versucht | *(leer)* | Neustart des Clients, Drucker aus- und eingeschaltet |
| Abschluss | erledigt | Warteschlange geleert, Spooler-Dienst neu gestartet; Ursache war ein hängender Auftrag von PC-042 |
## Prüfungsfrage
**Frage:** Nennen Sie drei Angaben, die eine Störungsmeldung enthalten muss, damit sie ohne
Rückfrage bearbeitet werden kann, und begründen Sie jeweils kurz.
**Antwort:** (1) Betroffenes System oder Gerät – sonst ist unklar, wo gesucht werden muss.
(2) Beobachtetes Verhalten samt Fehlermeldung im Wortlaut – erlaubt die Eingrenzung ohne
Rückfrage. (3) Zeitpunkt des ersten Auftretens – erlaubt den Abgleich mit Änderungen und
Protokolldateien. Zusätzlich sinnvoll: bereits durchgeführte Maßnahmen und die
Erreichbarkeit des Melders.
@@ -0,0 +1,46 @@
---
id: A2
titel: Das Gespräch am Telefon annehmen
block: arbeitsalltag
woche: 1
worum: Wer zuerst zuhört und dann in eigenen Worten wiederholt, spart sich die Hälfte der Rückfragen.
rahmenplan: [A2]
lernfeld: LF6
merksatz: Ausreden lassen, zusammenfassen, bestätigen lassen – erst dann den Rechner anfassen.
karten:
- frage: Was tust du im Kundengespräch, bevor du mit der Lösung beginnst?
antwort: Das Problem in eigenen Worten zusammenfassen und bestätigen lassen. So fällt sofort auf, wenn du etwas anderes verstanden hast als gemeint war.
---
## Das Wesentliche
- **Melden mit Name und Bereich.** „IT-Abteilung, Berger, guten Tag." Der Anrufer weiß
dann, mit wem er spricht, und du musst es nicht nachreichen.
- **Ausreden lassen.** Auch wenn nach zehn Sekunden klar scheint, worum es geht – die
letzten zwanzig enthalten oft die entscheidende Angabe.
- **Zurückspiegeln:** „Ich fasse zusammen: Seit heute Morgen kommen Sie nicht mehr ins
Warenwirtschaftssystem, am Nachbarplatz geht es. Habe ich das richtig?"
- **Keine Schuldzuweisung**, auch nicht scherzhaft. „Da haben Sie was verstellt" schließt
die Tür für die nächste Meldung.
- **Verbindlich abschließen:** Was passiert als Nächstes, bis wann, und wer meldet sich.
Lieber „ich melde mich bis 14 Uhr" als „ich schaue mal".
- **Sofort notieren.** Was du nicht innerhalb einer Minute ins Ticket schreibst, ist weg.
## Übersicht
| Satz, den man sich sparen sollte | Besser |
|---|---|
| Das kann eigentlich nicht sein. | Das habe ich so noch nicht gesehen – ich schaue es mir an. |
| Haben Sie schon neu gestartet? | Damit ich nichts doppelt mache: Was haben Sie bisher probiert? |
| Ich melde mich. | Ich melde mich heute bis 14 Uhr, auch wenn ich noch keine Lösung habe. |
| Da ist wohl der Server schuld. | Ich prüfe, ob es am Arbeitsplatz oder am Dienst liegt. |
## Prüfungsfrage
**Frage:** Warum ist es sinnvoll, das geschilderte Problem vor Beginn der Fehlersuche mit
eigenen Worten zu wiederholen?
**Antwort:** Weil Missverständnisse dadurch sofort auffallen, solange sie noch nichts
gekostet haben. Der Kunde kann korrigieren, bevor Arbeitszeit in die falsche Richtung
fließt. Zusätzlich zeigt die Zusammenfassung, dass zugehört wurde – das senkt die
Anspannung und verbessert die Zusammenarbeit.
+50
View File
@@ -0,0 +1,50 @@
---
id: A3
titel: Aufwand schätzen, ohne sich zu verschätzen
block: arbeitsalltag
woche: 1
worum: Eine Schätzung ist kein Versprechen, aber sie muss begründet und nachprüfbar sein.
rahmenplan: [A1, A7]
lernfeld: LF1
merksatz: In Arbeitsschritte zerlegen, jeden Schritt schätzen, den Puffer offen ausweisen.
karten:
- frage: Warum schätzt man Arbeitsschritte einzeln statt die Aufgabe als Ganzes?
antwort: Weil sich Schätzfehler beim Zerlegen ausgleichen statt aufzuaddieren, und weil sichtbar wird, welcher Schritt die Unsicherheit trägt.
- frage: Was gehört neben der reinen Arbeitszeit in eine Aufwandsschätzung?
antwort: Wege, Rüstzeiten, Wartezeiten auf Dritte, die Dokumentation und ein offen ausgewiesener Puffer für Unvorhergesehenes.
---
## Das Wesentliche
- **Zerlegen vor Schätzen.** Eine Aufgabe, die du nicht in Schritte zerlegen kannst, hast du
noch nicht verstanden – und kannst sie folglich nicht schätzen.
- **Drei Werte statt einem:** bester Fall, wahrscheinlicher Fall, schlechtester Fall. Wer nur
eine Zahl nennt, nennt immer den besten Fall.
- **Wartezeit ist keine Arbeitszeit**, kostet aber Durchlaufzeit. Beides getrennt nennen.
- **Der Puffer wird ausgewiesen, nicht versteckt.** Ein versteckter Puffer wird beim ersten
Nachverhandeln weggekürzt, ein begründeter nicht.
- **Nach der Arbeit vergleichen:** geschätzt gegen gebraucht. Nur so wird die nächste
Schätzung besser. Das ist kein Tadel, das ist Handwerk.
## Übersicht
| Schritt | Bester Fall | Wahrscheinlich | Schlechtester Fall |
|---|---|---|---|
| Gerät abholen und aufbauen | 15 min | 20 min | 40 min |
| Windows installieren | 30 min | 45 min | 90 min |
| Treiber und Fachanwendungen | 20 min | 40 min | 120 min |
| Ins Netz bringen, Funktionstest | 15 min | 20 min | 45 min |
| Dokumentation und Übergabe | 10 min | 15 min | 25 min |
| **Summe** | **1:30 h** | **2:20 h** | **5:20 h** |
## Prüfungsfrage
**Frage:** Ein Kunde fragt, wie lange die Einrichtung von zehn neuen Arbeitsplätzen dauert.
Wie gehen Sie bei der Schätzung vor?
**Antwort:** Zuerst die Arbeit für einen Arbeitsplatz in Schritte zerlegen und jeden Schritt
schätzen. Dann prüfen, welche Schritte sich bei zehn Geräten vorbereiten oder
parallelisieren lassen – ein einmal erstelltes Abbild ersetzt zehn Installationen, die Zeit
steigt also nicht linear. Rüst-, Weg- und Übergabezeiten je Gerät gesondert ansetzen,
Wartezeiten auf Zulieferungen als Durchlaufzeit ausweisen und einen begründeten Puffer
offenlegen. Das Ergebnis als Spanne nennen, nicht als eine Zahl.
+52
View File
@@ -0,0 +1,52 @@
---
id: H1
titel: Der Rechner von innen – was wo sitzt und warum
block: hardware
woche: 2
worum: Wer die Baugruppen und ihre Anschlüsse kennt, findet den Fehler durch Hinschauen statt durch Raten.
rahmenplan: [A3, B1]
lernfeld: LF2
merksatz: Strom, Signal, Kühlung – an einem dieser drei hängt fast jeder Hardwarefehler.
karten:
- frage: Woran erkennst du beim Öffnen eines PCs sofort ein defektes Netzteil oder Mainboard?
antwort: An aufgewölbten oder ausgelaufenen Elektrolytkondensatoren, an Brandspuren und an Verfärbungen rund um die Spannungswandler.
- frage: In welcher Reihenfolge steckst du RAM-Module bei zwei Modulen und vier Bänken?
antwort: In die beiden gleichfarbigen Bänke desselben Kanalpaares, meist Bank 2 und 4. Nur so läuft der Speicher im Zweikanalbetrieb – das Handbuch entscheidet.
---
## Das Wesentliche
- **Vor dem Öffnen:** Netzstecker ziehen, Reststrom über den Einschaltknopf abbauen,
erden. Ein statischer Schlag zerstört nicht sofort, sondern schleichend.
- **Mainboard** ist der Verteiler: CPU-Sockel, Speicherbänke, Steckplätze, Anschlüsse.
Alles hängt daran, deshalb ist es die letzte Verdächtige, nicht die erste.
- **Netzteil** liefert die Spannungen. Zu knapp bemessen oder gealtert äußert es sich als
sporadischer Neustart unter Last – nicht als sauberer Ausfall.
- **Arbeitsspeicher** paarweise und in die richtigen Bänke. Falsch bestückt läuft der
Rechner, aber langsamer, und niemand merkt es.
- **Datenträger:** SATA braucht Daten- und Stromkabel, M.2/NVMe steckt direkt auf dem
Board und teilt sich Leitungen mit anderen Steckplätzen.
- **Kühlung** ist Wartung, nicht Kosmetik. Verstaubte Kühlkörper drosseln die CPU, das
Gerät wird „einfach langsam".
## Übersicht
| Baugruppe | Typisches Fehlerbild | Erster Handgriff |
|---|---|---|
| Netzteil | Neustart unter Last, gar kein Start | Ersatznetzteil anschließen |
| Arbeitsspeicher | Bluescreens ohne Muster, Startton | Module einzeln testen, Bänke tauschen |
| Datenträger | Hängen, lange Ladezeiten, Klicken | SMART-Werte auslesen |
| Kühlung | Drosselung, Lüfter dauerhaft laut | Temperaturen auslesen, entstauben |
| Mainboard | gar kein Lebenszeichen, keine Anzeige | Sichtprüfung Kondensatoren, Diagnose-Anzeige |
## Prüfungsfrage
**Frage:** Ein Arbeitsplatzrechner startet mehrmals täglich ohne Fehlermeldung neu, sonst
läuft er unauffällig. Wie grenzen Sie die Ursache ein?
**Antwort:** Zuerst die Protokolle des Betriebssystems auf Kernel-Power-Ereignisse prüfen –
sie unterscheiden Absturz von sauberem Neustart. Dann Temperaturen und Lüfterdrehzahlen
unter Last beobachten (Drosselung oder Abschaltung durch Überhitzung). Anschließend
Arbeitsspeicher mit einem Speichertest prüfen und die Spannungsversorgung bewerten:
Neustarts ausschließlich unter Last deuten auf ein zu schwaches oder gealtertes Netzteil.
Ein Tausch gegen ein bekannt gutes Netzteil bestätigt oder widerlegt das schnell.
@@ -0,0 +1,53 @@
---
id: H2
titel: Datenträger beurteilen – SMART lesen statt hoffen
block: hardware
woche: 2
worum: Datenträger sterben selten plötzlich; sie kündigen es an, wenn man die richtigen Werte liest.
rahmenplan: [A3, A9]
lernfeld: LF2
merksatz: Bei „reallocated sectors" und „pending sectors" über null wird getauscht, nicht diskutiert.
karten:
- frage: Welche zwei SMART-Werte sind bei einer Festplatte das deutlichste Warnzeichen?
antwort: Reallocated Sector Count und Current Pending Sector Count. Steigt einer von beiden über null, wird der Datenträger getauscht.
- frage: Worin unterscheidet sich der Verschleiß einer SSD von dem einer Festplatte?
antwort: Die SSD verschleißt durch Schreibvorgänge – maßgeblich sind Media Wearout Indicator beziehungsweise Percentage Used und die geschriebene Gesamtdatenmenge. Die Festplatte verschleißt mechanisch.
---
## Das Wesentliche
- **SMART ist eine Selbstauskunft**, keine Garantie. Ein sauberer SMART-Wert schließt einen
Ausfall nicht aus – ein auffälliger Wert sagt aber verlässlich, dass es losgeht.
- **Festplatte:** Reallocated und Pending Sectors sind die beiden Werte, die zählen.
Alles über null bedeutet Tausch, nicht Beobachtung.
- **SSD:** Der Verschleiß hängt an geschriebenen Datenmengen. Percentage Used über
etwa 90 Prozent heißt: einplanen, nicht abwarten.
- **Betriebsstunden allein sagen wenig.** Eine Platte mit 60 000 Stunden und sauberen Werten
ist gesünder als eine mit 8 000 Stunden und wachsenden Fehlern.
- **Erst sichern, dann prüfen.** Diagnose auf einem angeschlagenen Datenträger kann ihm den
Rest geben.
- **Tausch dokumentieren:** Seriennummer, Einbaudatum, Grund. Sonst steht in zwei Jahren
niemand mehr fest, welches Gerät schon einmal auffällig war.
## Übersicht
| Wert | Bedeutung | Handeln ab |
|---|---|---|
| Reallocated Sector Count | ersetzte defekte Sektoren | > 0 |
| Current Pending Sector | Sektoren, die nicht gelesen werden konnten | > 0 |
| Uncorrectable Sector Count | endgültig verlorene Sektoren | > 0 |
| Percentage Used (SSD) | verbrauchte Schreiblebensdauer | > 90 % |
| Power On Hours | Betriebsstunden | nur im Zusammenhang |
| Temperature | Betriebstemperatur | dauerhaft > 50 °C prüfen |
## Prüfungsfrage
**Frage:** Ein Anwender meldet, sein Rechner „hänge manchmal minutenlang". Welche
Hardwareursache prüfen Sie zuerst und wie?
**Antwort:** Zuerst den Datenträger. Minutenlange Hänger ohne CPU-Last sind das typische
Bild wiederholter Leseversuche auf schwachen Sektoren. Vorgehen: zunächst eine Sicherung
der Nutzerdaten anlegen, dann SMART-Werte auslesen und auf Reallocated und Pending Sectors
achten, ergänzend das Ereignisprotokoll auf Datenträgerfehler durchsehen. Bestätigt sich
der Verdacht, wird der Datenträger getauscht und nicht repariert – die Daten sind teurer
als das Bauteil.
@@ -0,0 +1,53 @@
---
id: H3
titel: Geräte vergleichen und eine Empfehlung begründen
block: hardware
woche: 2
worum: Ein Gerätevergleich ist erst dann etwas wert, wenn er sich am Einsatzzweck misst und nicht am Datenblatt.
rahmenplan: [A3, A2]
lernfeld: LF2
merksatz: Erst den Einsatzzweck aufschreiben, dann die Kriterien, dann erst die Geräte suchen.
karten:
- frage: In welcher Reihenfolge gehst du bei einem Gerätevergleich vor?
antwort: Einsatzzweck klären, daraus Kriterien mit Gewichtung ableiten, dann Geräte suchen und bewerten. Wer mit den Geräten anfängt, sucht sich die Kriterien passend zum Wunschgerät.
- frage: Warum gehören Gesamtkosten statt Anschaffungspreis in einen Gerätevergleich?
antwort: Weil Garantieverlängerung, Ersatzteilverfügbarkeit, Energieverbrauch und Wartungsaufwand über die Nutzungsdauer den Preisunterschied oft übersteigen.
---
## Das Wesentliche
- **Der Einsatzzweck kommt zuerst.** „Notebook für den Außendienst, acht Stunden ohne
Steckdose, robust" ist eine Anforderung. „Ein gutes Notebook" ist keine.
- **Kriterien gewichten**, bevor Geräte angeschaut werden. Sonst wird das Wunschgerät
nachträglich begründet.
- **Nutzwertanalyse:** Kriterium, Gewicht, Punktzahl je Gerät, Produkt. Das Ergebnis ist
keine Wahrheit, aber es macht die Entscheidung nachvollziehbar.
- **Gesamtkosten statt Kaufpreis:** Garantie, Ersatzteile, Zubehör, Strom, Aufwand für die
Einrichtung. Über vier Jahre gerechnet dreht sich die Reihenfolge häufig.
- **Verfügbarkeit prüfen**, bevor die Empfehlung raus geht. Das beste Gerät nützt nichts,
wenn es zwölf Wochen Lieferzeit hat.
- **Empfehlung schriftlich** mit einem Satz Begründung je Kriterium. Der Kunde entscheidet,
nicht die IT – aber er entscheidet auf Grundlage deiner Aufbereitung.
## Übersicht
| Kriterium | Gewicht | Gerät A | Gerät B | Gerät C |
|---|---|---|---|---|
| Akkulaufzeit | 30 % | 8 | 6 | 9 |
| Robustheit | 25 % | 7 | 9 | 5 |
| Gewicht | 15 % | 6 | 4 | 9 |
| Ersatzteile und Garantie | 20 % | 8 | 9 | 5 |
| Preis | 10 % | 6 | 5 | 8 |
| **Nutzwert** | **100 %** | **7,25** | **6,95** | **7,05** |
## Prüfungsfrage
**Frage:** Erläutern Sie, warum eine Nutzwertanalyse einer reinen Preisbetrachtung
vorzuziehen ist, und nennen Sie eine Schwäche des Verfahrens.
**Antwort:** Die Nutzwertanalyse bildet ab, dass ein Gerät mehrere Anforderungen
unterschiedlich gut erfüllt, und macht die Gewichtung dieser Anforderungen sichtbar und
diskutierbar. Eine reine Preisbetrachtung blendet Folgekosten und Eignung aus. Schwäche:
Gewichte und Punktvergabe sind subjektiv; wer das Ergebnis kennen will, kann es durch die
Wahl der Gewichte herbeiführen. Deshalb müssen Gewichte vor der Gerätesuche festgelegt und
begründet werden.
+51
View File
@@ -0,0 +1,51 @@
---
id: N1
titel: Das Schichtenmodell als Werkzeug der Fehlersuche
block: netzwerk
woche: 3
worum: Das Schichtenmodell ist keine Theorie zum Auswendiglernen, sondern eine Reihenfolge für die Fehlersuche.
rahmenplan: [B2, A8]
lernfeld: LF3
merksatz: Von unten nach oben prüfen – Kabel vor IP, IP vor Namen, Namen vor Anwendung.
karten:
- frage: In welcher Reihenfolge suchst du einen Netzwerkfehler und warum?
antwort: Von unten nach oben – erst Leitung und Verbindung, dann IP-Adresse und Weg, dann Namensauflösung, zuletzt die Anwendung. Eine untere Schicht trägt alle darüber, also ist jede Prüfung oben wertlos, solange unten etwas fehlt.
- frage: Auf welcher Schicht arbeiten Switch, Router und Firewall mit Anwendungsfilter?
antwort: Der Switch auf Schicht 2 (MAC-Adressen), der Router auf Schicht 3 (IP-Adressen), eine Firewall mit Anwendungsfilter bis hinauf auf Schicht 7.
---
## Das Wesentliche
- **Vier Schichten reichen im Alltag:** Leitung, Netz, Transport, Anwendung. Die sieben
Schichten des OSI-Modells braucht man für die Prüfung, die vier für die Arbeit.
- **Immer von unten prüfen.** Die Verbindungsanzeige am Anschluss zu übersehen und
stattdessen DNS zu prüfen, kostet regelmäßig eine Stunde.
- **Jede Schicht hat ein eigenes Werkzeug:** Anzeige-LED und Kabeltester unten,
`ipconfig` und `ping` in der Mitte, `nslookup` darüber, Browser und Protokolldatei oben.
- **Ein Gerät kann auf mehreren Schichten arbeiten.** Ein „Switch" mit Routingfunktion ist
beides – bei der Fehlersuche muss man wissen, welche Rolle er gerade spielt.
- **Kapselung heißt:** Jede Schicht packt ihre eigene Kopfzeile davor. Deshalb sieht man im
Mitschnitt außen Ethernet, dann IP, dann TCP, dann erst die Nutzdaten.
## Übersicht
| Schicht | Adresse | Gerät | Werkzeug | Typische Störung |
|---|---|---|---|---|
| Anwendung | URL, Name | Server, Anwendung | Browser, Protokolle | Dienst gestoppt, Anmeldung falsch |
| Transport | Port | Firewall | `Test-NetConnection` | Port gesperrt |
| Netz | IP-Adresse | Router | `ping`, `tracert` | falsche Adresse, kein Weg |
| Leitung | MAC-Adresse | Switch, Kabel | LED, Kabeltester | Kabelbruch, Anschluss aus |
## Prüfungsfrage
**Frage:** Ein Anwender kann eine interne Webanwendung nicht erreichen, andere Kollegen im
selben Netz schon. Beschreiben Sie Ihr Vorgehen anhand des Schichtenmodells.
**Antwort:** Schicht 1 und 2: Verbindungsanzeige am Anschluss und am Switch prüfen, Kabel
und Anschlussdose kontrollieren. Schicht 3: mit `ipconfig` prüfen, ob eine gültige Adresse
und ein Standardgateway vorhanden sind, danach das Gateway und den Server anpingen.
Schicht 4: mit `Test-NetConnection` prüfen, ob der Zielport erreichbar ist – schlägt das bei
erreichbarer IP fehl, ist eine Firewallregel wahrscheinlich. Erst danach Schicht 7: Name mit
`nslookup` auflösen, Anmeldedaten, Browserzwischenspeicher und die Protokolle der Anwendung
prüfen. Da andere Kollegen es erreichen, liegt die Ursache erfahrungsgemäß unten oder in
der Konfiguration dieses einen Arbeitsplatzes.
@@ -0,0 +1,52 @@
---
id: N2
titel: IP-Adresse und Subnetzmaske – wer gehört zusammen?
block: netzwerk
woche: 3
worum: Die Maske entscheidet, welchen Teil der Adresse das Netz ausmacht und welchen der einzelne Rechner.
rahmenplan: [B2]
lernfeld: LF3
merksatz: Gleiche Netzadresse heißt direkt erreichbar, alles andere geht über das Gateway.
karten:
- frage: Woran erkennt ein Rechner, ob er ein Ziel direkt erreichen kann oder über das Gateway muss?
antwort: Er verknüpft die eigene und die Zieladresse jeweils mit der Subnetzmaske. Kommt dieselbe Netzadresse heraus, liegt das Ziel im selben Netz und wird direkt angesprochen, sonst geht es über das Standardgateway.
- frage: Welche Adressbereiche sind privat und werden im Internet nicht geroutet?
antwort: 10.0.0.0/8, 172.16.0.0/12 und 192.168.0.0/16. Dazu kommt 169.254.0.0/16 für die Selbstvergabe, wenn kein DHCP antwortet.
---
## Das Wesentliche
- **Eine IPv4-Adresse hat 32 Bit**, geschrieben als vier Zahlen von 0 bis 255. Die Maske
sagt, wie viele Bits von links das Netz bezeichnen – `/24` heißt 24 Bit Netz, 8 Bit Rechner.
- **Netzadresse** ist die kleinste Adresse im Netz (alle Rechner-Bits null), **Broadcast**
die größte (alle Rechner-Bits eins). Beide sind vergeben, keinem Gerät zuweisbar.
- **Nutzbare Adressen** sind Blockgröße minus zwei. Bei `/24`: 256 − 2 = 254.
- **Gleiche Netzadresse = direkt erreichbar.** Das ist der einzige Zweck der Maske, alles
andere folgt daraus.
- **169.254.x.x** bedeutet: kein DHCP-Server geantwortet. Das ist keine Adresse, das ist
eine Fehlermeldung mit vier Zahlen.
- **Privatbereiche** werden im Internet nicht weitergeleitet und dürfen im Betrieb frei
verwendet werden – aber nicht doppelt an zwei Standorten, die sich später verbinden sollen.
## Übersicht
| CIDR | Subnetzmaske | Blockgröße | Nutzbar |
|------|--------------|-----------|---------|
| /24 | 255.255.255.0 | 256 | 254 |
| /25 | 255.255.255.128 | 128 | 126 |
| /26 | 255.255.255.192 | 64 | 62 |
| /27 | 255.255.255.224 | 32 | 30 |
| /28 | 255.255.255.240 | 16 | 14 |
| /29 | 255.255.255.248 | 8 | 6 |
| /30 | 255.255.255.252 | 4 | 2 |
## Prüfungsfrage
**Frage:** Ein Arbeitsplatz hat die Adresse 192.168.20.130 mit der Maske 255.255.255.128,
das Standardgateway ist 192.168.20.1. Warum funktioniert das nicht?
**Antwort:** Die Maske 255.255.255.128 entspricht `/25`, die Blockgröße ist 128. Es gibt
also die Netze 192.168.20.0/25 (Adressen .0 bis .127) und 192.168.20.128/25 (.128 bis .255).
Der Arbeitsplatz liegt mit .130 im zweiten Netz, das Gateway mit .1 im ersten. Das Gateway
ist damit nicht direkt erreichbar, und der Rechner kommt aus seinem Netz nicht heraus.
Richtig wäre ein Gateway aus dem Bereich .129 bis .254, üblicherweise 192.168.20.129.
+54
View File
@@ -0,0 +1,54 @@
---
id: N3
titel: Subnetting rechnen – der Weg in vier Schritten
block: netzwerk
woche: 3
worum: Eine Adresse und eine Maske reichen, um Netz, Broadcast und Hostbereich zu bestimmen.
rahmenplan: [B2, A8]
lernfeld: LF3
merksatz: Blockgröße finden, Vielfache aufschreiben, eigene Adresse einsortieren.
karten:
- frage: Zu welchem Netz gehört 192.168.10.200 bei der Maske 255.255.255.224?
antwort: Blockgröße 32, Netze bei .192 und .224 – die Adresse gehört zu 192.168.10.192/27. Broadcast ist .223, nutzbar sind .193 bis .222.
- frage: Wie berechnest du die Blockgröße aus der CIDR-Angabe?
antwort: Blockgröße = 256 minus dem letzten von null verschiedenen Oktett der Maske, oder gleichwertig 2 hoch (32 minus Präfixlänge), bezogen auf das betroffene Oktett.
---
## Das Wesentliche
- **Schritt 1 – Blockgröße bestimmen.** Interessant ist das Oktett, in dem die Maske
„bricht". Blockgröße = 256 − Maskenwert in diesem Oktett. Bei `/27` ist die Maske
255.255.255.224, also 256 − 224 = **32**.
- **Schritt 2 – Vielfache aufschreiben.** 0, 32, 64, 96, 128, 160, 192, 224. Das sind alle
Netzadressen in diesem Oktett.
- **Schritt 3 – eigene Adresse einsortieren.** Das größte Vielfache, das nicht größer ist als
die eigene Adresse, ist die Netzadresse. Für .200 ist das **192**.
- **Schritt 4 – Grenzen ablesen.** Broadcast = nächste Netzadresse − 1, hier 224 − 1 = **223**.
Nutzbar ist alles dazwischen: **.193 bis .222**.
- **Gegenprobe:** nutzbare Adressen = Blockgröße − 2. Hier 30. Von .193 bis .222 sind es
genau 30. Passt.
- **Nie im Kopf über mehrere Oktette rechnen.** Wenn die Maske im dritten Oktett bricht,
gilt dasselbe Verfahren dort, und das vierte Oktett läuft von 0 bis 255 durch.
## Übersicht
| CIDR | Maske | Blockgröße | Netze je Oktett | Nutzbar |
|------|-------|-----------|-----------------|---------|
| /25 | 255.255.255.128 | 128 | 2 | 126 |
| /26 | 255.255.255.192 | 64 | 4 | 62 |
| /27 | 255.255.255.224 | 32 | 8 | 30 |
| /28 | 255.255.255.240 | 16 | 16 | 14 |
| /29 | 255.255.255.248 | 8 | 32 | 6 |
| /30 | 255.255.255.252 | 4 | 64 | 2 |
## Prüfungsfrage
**Frage:** Ein Standort benötigt vier getrennte Netze mit jeweils mindestens 25 Geräten. Zur
Verfügung steht 192.168.50.0/24. Wie teilen Sie auf?
**Antwort:** 25 Geräte brauchen mindestens 27 Adressen (25 Hosts, Netzadresse, Broadcast),
also eine Blockgröße von 32 – das entspricht `/27` mit 30 nutzbaren Adressen. Aus einem
`/24` entstehen acht `/27`-Netze, vier davon werden vergeben, vier bleiben in Reserve:
192.168.50.0/27 (nutzbar .1–.30), 192.168.50.32/27 (.33–.62), 192.168.50.64/27 (.65–.94),
192.168.50.96/27 (.97–.126). Eine Aufteilung in `/26` wäre ebenfalls möglich und ließe mehr
Luft nach oben, aber nur vier Netze insgesamt – bei absehbarem Wachstum die schlechtere Wahl.
+51
View File
@@ -0,0 +1,51 @@
---
id: N4
titel: DHCP und DNS – Adresse bekommen, Namen auflösen
block: netzwerk
woche: 3
worum: DHCP verteilt Adressen, DNS übersetzt Namen – zwei Dienste, zwei ganz verschiedene Fehlerbilder.
rahmenplan: [B2, B3]
lernfeld: LF3
merksatz: Keine Adresse heißt DHCP, keine Auflösung heißt DNS – und 169.254 heißt beides nicht erreicht.
karten:
- frage: Welche vier Schritte umfasst der DHCP-Ablauf und wie heißen sie?
antwort: Discover, Offer, Request, Acknowledge – der Client sucht, der Server bietet an, der Client fordert das Angebot an, der Server bestätigt.
- frage: Ein Rechner kann 8.8.8.8 anpingen, aber keine Internetseite öffnen. Wo liegt der Fehler?
antwort: Bei der Namensauflösung. Die IP-Verbindung steht, also ist DNS falsch konfiguriert oder der DNS-Server antwortet nicht.
---
## Das Wesentliche
- **DHCP vergibt Adressen auf Zeit.** Der Mietzeitraum („Lease") läuft ab, der Client
verlängert nach der Hälfte der Zeit selbstständig.
- **Vier Schritte:** Discover, Offer, Request, Acknowledge. Merkhilfe: **D-O-R-A**.
- **Reservierung statt fester Adresse.** Drucker und Server bekommen im DHCP eine feste
Zuordnung über die MAC-Adresse – so bleibt die Verwaltung an einer Stelle.
- **DNS übersetzt Namen in Adressen**, in Stufen: eigener Zwischenspeicher, hosts-Datei,
konfigurierter Server, dessen Weiterleitung.
- **Die beiden Fehlerbilder unterscheiden sich klar.** Keine oder eine 169.254-Adresse:
DHCP. Adresse vorhanden, `ping` auf eine IP funktioniert, Namen nicht: DNS.
- **Zwischenspeicher leeren, bevor man verzweifelt:** `ipconfig /flushdns` auf dem Client,
und daran denken, dass Browser einen eigenen Speicher haben.
## Übersicht
| Beobachtung | Wahrscheinliche Ursache | Prüfung |
|---|---|---|
| Adresse 169.254.x.x | kein DHCP-Server erreichbar | `ipconfig /all`, Anschluss, VLAN |
| Adresse da, Gateway nicht erreichbar | falsches Netz oder Maske | `ping` Gateway, Maske prüfen |
| IP erreichbar, Name nicht | DNS | `nslookup name`, DNS-Server prüfen |
| Name löst auf alte Adresse auf | Zwischenspeicher | `ipconfig /flushdns` |
| Nur eine Anwendung geht nicht | Port oder Dienst | `Test-NetConnection -Port` |
## Prüfungsfrage
**Frage:** Nach dem Austausch eines Servers erreichen einige Arbeitsplätze den Dateidienst
weiterhin nicht, andere sofort. Woran liegt das und was tun Sie?
**Antwort:** Die betroffenen Arbeitsplätze haben den alten DNS-Eintrag noch im
Zwischenspeicher; die Gültigkeitsdauer des Eintrags ist noch nicht abgelaufen. Kurzfristig
hilft `ipconfig /flushdns` auf den betroffenen Rechnern beziehungsweise ein Neustart.
Grundsätzlich: vor geplanten Umstellungen die Gültigkeitsdauer des betroffenen
DNS-Eintrags rechtzeitig herabsetzen, umstellen, und sie danach wieder anheben. Zusätzlich
prüfen, ob auf dem DNS-Server noch ein veralteter Eintrag steht.
@@ -0,0 +1,54 @@
---
id: S1
titel: Windows sauber installieren und übergabefertig machen
block: system
woche: 4
worum: Eine Installation ist erst fertig, wenn Treiber, Updates, Sicherung und Dokumentation stehen – nicht wenn der Anmeldebildschirm erscheint.
rahmenplan: [B1, B3, A5]
lernfeld: LF2
merksatz: Erst Daten sichern, dann installieren – die Reihenfolge ist nicht verhandelbar.
karten:
- frage: Was prüfst du vor jeder Neuinstallation zuerst?
antwort: Ob die Nutzerdaten gesichert sind und die Sicherung nachweislich zurückspielbar ist. Danach Lizenz, Treiberverfügbarkeit und benötigte Fachanwendungen.
- frage: Warum trennt man Betriebssystem und Nutzerdaten auf getrennte Ablagen?
antwort: Weil das Betriebssystem jederzeit neu aufgesetzt werden können muss, ohne dass Nutzerdaten davon betroffen sind.
---
## Das Wesentliche
- **Vorher sichern und die Sicherung prüfen.** Eine ungeprüfte Sicherung ist keine.
- **Installationsmedium aktuell halten.** Ein zwei Jahre altes Abbild bedeutet 300 Updates
nach der Installation und einen halben Tag Wartezeit.
- **Treiber vom Hersteller**, nicht aus der Suchmaschine. Chipsatz zuerst, dann Netzwerk,
dann der Rest.
- **Updates in Ruhe durchlaufen lassen**, bevor Fachanwendungen installiert werden. Sonst
reißt ein Neustart eine halb installierte Anwendung auseinander.
- **Konten sauber trennen:** ein lokales Administratorkonto für die Verwaltung, ein
normales Konto für die tägliche Arbeit. Nicht als Administrator arbeiten.
- **Übergabe ist Teil der Arbeit:** Rechnername, Adresse, Lizenz, installierte Software und
Datum in die Dokumentation. Danach eine kurze Einweisung des Anwenders.
## Übersicht
| Schritt | Fertig, wenn … |
|---|---|
| Sicherung | Rückspielen einer Beispieldatei erfolgreich getestet |
| Installation | Windows startet, Datenträger sinnvoll aufgeteilt |
| Treiber | Geräte-Manager ohne gelbe Ausrufezeichen |
| Updates | Windows Update meldet „Sie sind auf dem neuesten Stand" |
| Software | alle Fachanwendungen gestartet und angemeldet getestet |
| Netz und Konten | Domänenbeitritt erfolgt, Anmeldung mit Nutzerkonto geprüft |
| Dokumentation | Rechnername, Seriennummer, Lizenz, Datum eingetragen |
| Übergabe | Anwender hat sich selbst angemeldet und quittiert |
## Prüfungsfrage
**Frage:** Begründen Sie, warum Fachanwendungen erst nach dem vollständigen Einspielen der
Betriebssystemaktualisierungen installiert werden sollten.
**Antwort:** Aktualisierungen tauschen Systemkomponenten und Laufzeitbibliotheken aus und
erzwingen Neustarts. Werden Fachanwendungen vorher installiert, können sie gegen
Komponenten gebunden sein, die anschließend ersetzt werden; außerdem kann ein erzwungener
Neustart eine laufende Installation abbrechen und einen inkonsistenten Zustand
hinterlassen. Erst ein vollständig aktualisiertes System bietet eine stabile Grundlage –
und Fehler bei der Anwendungsinstallation sind dann eindeutig der Anwendung zuzuordnen.
@@ -0,0 +1,53 @@
---
id: S2
titel: Benutzer, Gruppen und Berechtigungen
block: system
woche: 4
worum: Rechte werden an Gruppen vergeben, nie an einzelne Personen – sonst weiß nach zwei Jahren niemand mehr, warum jemand irgendwo hineinkommt.
rahmenplan: [B3, A6]
lernfeld: LF4
merksatz: Rechte an Gruppen, Personen in Gruppen. So wenig wie möglich, so viel wie nötig.
karten:
- frage: Warum vergibt man Berechtigungen an Gruppen statt an einzelne Benutzer?
antwort: Weil beim Wechsel einer Person nur die Gruppenzugehörigkeit geändert werden muss und jederzeit nachvollziehbar bleibt, warum jemand ein Recht hat.
- frage: Was setzt sich durch, wenn Freigabe- und NTFS-Berechtigungen sich widersprechen?
antwort: Die jeweils restriktivere Berechtigung. Über das Netz gilt der Schnitt aus beiden, lokal nur die NTFS-Berechtigung.
- frage: Was besagt das Prinzip der geringsten Rechte?
antwort: Jedes Konto bekommt genau die Rechte, die es für seine Aufgabe braucht, und nicht mehr – auf Zeit vergebene Rechte werden wieder entzogen.
---
## Das Wesentliche
- **Rechte an Gruppen, Personen in Gruppen.** Die Gruppe trägt die Begründung im Namen:
`FS-Buchhaltung-Aendern` ist selbsterklärend, `Rechte_Meier` nicht.
- **Prinzip der geringsten Rechte.** Kein Tagesgeschäft mit Administratorrechten. Erhöhte
Rechte werden angefordert, benutzt und wieder abgelegt.
- **Vererbung verstehen**, bevor man sie unterbricht. Ein unterbrochener Vererbungspfad ist
in zwei Jahren nicht mehr nachvollziehbar.
- **Verweigern schlägt Erlauben.** Eine explizite Verweigerung setzt sich gegen jede
Erlaubnis durch – deshalb sparsam einsetzen, sonst sucht man sich später tot.
- **Freigabe und NTFS greifen zusammen:** Über das Netz gilt die restriktivere der beiden.
Übliche Praxis: Freigabe auf „Jeder – Vollzugriff", die eigentliche Steuerung über NTFS.
- **Austritt heißt sperren, nicht löschen.** Gelöschte Konten reißen Berechtigungen und
Dateibesitz auseinander.
## Übersicht
| Berechtigung | Erlaubt | Typisch für |
|---|---|---|
| Lesen | Öffnen, Auflisten | Nachschlagewerke, Vorlagen |
| Lesen und Ausführen | zusätzlich Programme starten | Softwareablagen |
| Ändern | zusätzlich Anlegen, Ändern, Löschen | Arbeitsverzeichnisse einer Abteilung |
| Vollzugriff | zusätzlich Berechtigungen ändern | ausschließlich Administratoren |
## Prüfungsfrage
**Frage:** Eine Mitarbeiterin wechselt von der Buchhaltung in den Einkauf. Wie gehen Sie mit
ihren Berechtigungen um?
**Antwort:** Das Konto bleibt bestehen; geändert wird ausschließlich die
Gruppenzugehörigkeit: Entfernen aus den Gruppen der Buchhaltung, Aufnahme in die Gruppen
des Einkaufs. Direkt am Konto vergebene Einzelberechtigungen sind zu suchen und zu
entfernen – sie sind der Grund, warum Rechte über Jahre mitwandern. Die Änderung wird mit
Datum und Anlass dokumentiert, und nach dem Wechsel wird stichprobenartig geprüft, dass der
Zugriff auf die Buchhaltungsablagen tatsächlich nicht mehr besteht.
+74
View File
@@ -0,0 +1,74 @@
id: woche-01
woche: 1
titel: Lernzielkontrolle Woche 1 – Arbeitsalltag und Auftrag
dauer_minuten: 30
hinweis: >-
Offene Fragen, keine Hilfsmittel. Antworte in ganzen Sätzen – bei der
Abschlussprüfung wird ebenfalls nicht in Stichworten bewertet.
fragen:
- id: f1
frage: >-
Nennen Sie vier Angaben, die in jede Störungsmeldung gehören, und
begründen Sie bei zweien davon, warum sie ohne Rückfrage nötig sind.
punkte: 8
musterloesung: >-
Betroffene Person und Gerät oder Dienst; das beobachtete Verhalten
einschließlich Fehlermeldung im Wortlaut; der Zeitpunkt des ersten
Auftretens; die bereits durchgeführten Maßnahmen. Begründungen: Der
Zeitpunkt erlaubt den Abgleich mit Änderungen und Protokolldateien und
grenzt damit den Ursachenraum ein. Die bereits durchgeführten Maßnahmen
verhindern, dass der Bearbeiter dieselben Schritte wiederholt und Zeit
verliert. (Je Angabe 1 Punkt, je Begründung 2 Punkte.)
- id: f2
frage: >-
Ein Kollege schreibt als Betreff "Rechner spinnt". Formulieren Sie einen
brauchbaren Betreff und erklären Sie in einem Satz, woran man einen guten
Betreff erkennt.
punkte: 4
musterloesung: >-
Beispiel: "PC-042 Buchhaltung – Bluescreen beim Start von Word, seit
heute 09:15". Ein guter Betreff nennt Gerät oder Dienst, das Fehlerbild
und den Zeitpunkt und ist damit in der Ticketübersicht allein verständlich,
ohne dass das Ticket geöffnet werden muss.
- id: f3
frage: >-
Warum wiederholt man im Kundengespräch das geschilderte Problem in eigenen
Worten, bevor man mit der Fehlersuche beginnt?
punkte: 4
musterloesung: >-
Weil Missverständnisse dadurch auffallen, bevor Arbeitszeit in die falsche
Richtung fließt. Der Kunde kann sofort korrigieren. Zusätzlich zeigt die
Zusammenfassung, dass zugehört wurde, was die Anspannung senkt und die
weitere Zusammenarbeit erleichtert.
- id: f4
frage: >-
Sie sollen den Aufwand für die Einrichtung von zehn Arbeitsplätzen
schätzen. Beschreiben Sie Ihr Vorgehen in vier Schritten.
punkte: 8
musterloesung: >-
(1) Die Arbeit für einen Arbeitsplatz in einzelne Schritte zerlegen.
(2) Jeden Schritt getrennt schätzen, jeweils mit bestem, wahrscheinlichem
und schlechtestem Fall. (3) Prüfen, welche Schritte sich für zehn Geräte
vorbereiten oder parallelisieren lassen – ein einmal erstelltes Abbild
ersetzt zehn Installationen, die Zeit steigt also nicht linear. (4) Weg-,
Rüst- und Übergabezeiten je Gerät ergänzen, Wartezeiten als Durchlaufzeit
getrennt ausweisen und einen begründeten Puffer offenlegen; das Ergebnis
als Spanne nennen.
- id: f5
frage: >-
Was gehört in ein Ticket, wenn Sie es schließen? Nennen Sie zwei Dinge und
erklären Sie, warum "erledigt" nicht genügt.
punkte: 6
musterloesung: >-
Die festgestellte Ursache und die durchgeführte Maßnahme, idealerweise
ergänzt um den tatsächlichen Aufwand. "Erledigt" genügt nicht, weil das
Ticket die einzige Dokumentation des Vorgangs ist: Tritt der Fehler erneut
auf oder bearbeitet ihn jemand anderes, ist ohne Ursache und Maßnahme die
gesamte Fehlersuche zu wiederholen. Außerdem lässt sich ohne diese Angaben
nicht erkennen, ob ein Fehler häufig auftritt und eine dauerhafte Lösung
braucht.
+86
View File
@@ -0,0 +1,86 @@
id: woche-03
woche: 3
titel: Lernzielkontrolle Woche 3 – Netzwerkgrundlagen
dauer_minuten: 45
hinweis: >-
Offene Fragen. Bei Rechenaufgaben zählt der Rechenweg – ein richtiges Ergebnis
ohne Weg gibt die halbe Punktzahl.
fragen:
- id: f1
frage: >-
Bestimmen Sie zu 192.168.10.200 mit der Maske 255.255.255.224 die
Netzadresse, die Broadcastadresse und den nutzbaren Hostbereich. Zeigen Sie
den Rechenweg.
punkte: 8
musterloesung: >-
Maske 255.255.255.224 entspricht /27. Blockgröße = 256 − 224 = 32.
Vielfache von 32: 0, 32, 64, 96, 128, 160, 192, 224. Das größte Vielfache,
das 200 nicht übersteigt, ist 192 – Netzadresse also 192.168.10.192/27.
Broadcast = nächste Netzadresse (224) minus 1 = 192.168.10.223. Nutzbar:
192.168.10.193 bis 192.168.10.222, das sind 30 Adressen (Blockgröße minus
zwei). (Blockgröße 2 P., Netzadresse 2 P., Broadcast 2 P., Hostbereich 2 P.)
- id: f2
frage: >-
Ein Standort braucht vier getrennte Netze mit je mindestens 25 Geräten.
Zur Verfügung steht 192.168.50.0/24. Wie teilen Sie auf und warum?
punkte: 8
musterloesung: >-
25 Geräte brauchen 27 Adressen (25 Hosts plus Netzadresse plus Broadcast),
also Blockgröße 32 und damit /27 mit 30 nutzbaren Adressen. Aus einem /24
entstehen acht /27-Netze; vier werden vergeben, vier bleiben Reserve:
192.168.50.0/27, .32/27, .64/27, .96/27. Eine Aufteilung in /26 wäre
ebenfalls ausreichend, ergäbe aber nur vier Netze insgesamt und damit keine
Reserve – bei absehbarem Wachstum die schlechtere Wahl.
- id: f3
frage: >-
Ein Arbeitsplatz meldet die Adresse 169.254.13.7. Was bedeutet das, und
welche drei Prüfungen führen Sie in welcher Reihenfolge durch?
punkte: 6
musterloesung: >-
169.254.x.x ist eine Selbstvergabe des Clients (APIPA); sie bedeutet, dass
kein DHCP-Server geantwortet hat. Reihenfolge von unten nach oben:
(1) Schicht 1 – Kabel und Verbindungsanzeige an Gerät, Dose und Switch.
(2) Schicht 2 – ist der Switchport aktiv und im richtigen VLAN?
(3) DHCP – antwortet der Server, ist der Adressbereich erschöpft, ist die
DHCP-Weiterleitung im betreffenden Netz eingerichtet?
- id: f4
frage: >-
Ein Rechner kann 8.8.8.8 anpingen, aber keine Internetseite öffnen. Wo
liegt der Fehler, und wie weisen Sie ihn nach?
punkte: 5
musterloesung: >-
Bei der Namensauflösung. Die IP-Verbindung nach außen steht, also ist
entweder kein oder ein falscher DNS-Server eingetragen, oder der
eingetragene Server antwortet nicht. Nachweis: "nslookup www.example.com"
schlägt fehl, während der Ping auf die IP-Adresse funktioniert; ergänzend
"ipconfig /all" auf den eingetragenen DNS-Server prüfen und testweise einen
bekannt funktionierenden Server abfragen.
- id: f5
frage: >-
Ordnen Sie Switch, Router und Kabel den Schichten des Schichtenmodells zu
und erklären Sie, warum man bei der Fehlersuche unten anfängt.
punkte: 6
musterloesung: >-
Kabel: Schicht 1 (Bitübertragung). Switch: Schicht 2 (MAC-Adressen).
Router: Schicht 3 (IP-Adressen). Man beginnt unten, weil jede Schicht auf
der darunterliegenden aufsetzt: Fehlt die physische Verbindung, ist jede
Prüfung auf IP- oder Namensebene ohne Aussagekraft. Von unten nach oben zu
arbeiten schließt außerdem mit jedem Schritt einen ganzen Bereich
möglicher Ursachen aus.
- id: f6
frage: >-
Nennen Sie die vier Schritte des DHCP-Ablaufs in der richtigen Reihenfolge
und erklären Sie einen davon.
punkte: 5
musterloesung: >-
Discover, Offer, Request, Acknowledge. Beispiel Offer: Der DHCP-Server
antwortet auf die Suchanfrage des Clients mit einem konkreten
Adressangebot samt Maske, Gateway, DNS-Servern und Mietdauer; die Adresse
ist damit noch nicht vergeben, sondern nur vorgemerkt, bis der Client sie
mit Request anfordert.
+205
View File
@@ -0,0 +1,205 @@
# ---------------------------------------------------------------------------
# Nachschlagewerk für die beiden verbindlichen Dokumente der Ausbildung.
# Diese Datei wird nicht bearbeitet, um Inhalte zu planen – sie bildet ab, was
# in Verordnung und Rahmenlehrplan steht. Alles andere verweist nur darauf.
#
# Quellen:
# Betrieb: Verordnung über die Berufsausbildung zum Fachinformatiker und zur
# Fachinformatikerin (FIAusbV) vom 28.02.2020, Anlage zu § 3 Abs. 1
# Schule: Rahmenlehrplan der Kultusministerkonferenz für die Ausbildungs-
# berufe der IT-Berufe, Beschluss vom 13.12.2019
#
# Zur Nummerierung: Die Verordnung führt sechs Abschnitte (A bis F), weil sie
# alle vier Fachrichtungen abdeckt. Für die Systemintegration sind davon drei
# einschlägig. Um die Kürzel kurz zu halten, werden sie hier so benannt:
#
# A1 – A10 = Abschnitt A der Verordnung (fachrichtungsübergreifend)
# B1 – B3 = Abschnitt C der Verordnung (Fachrichtung Systemintegration)
# C1 – C5 = Abschnitt F der Verordnung (integrativ zu vermitteln)
#
# Das Feld `verordnung` nennt bei jeder Position die amtliche Fundstelle,
# damit gegenüber der IHK nachvollziehbar bleibt, was gemeint ist.
# ---------------------------------------------------------------------------
quellen:
betrieb:
titel: Verordnung über die Berufsausbildung zum Fachinformatiker und zur Fachinformatikerin
kurz: FIAusbV
vom: 2020-02-28
fundstelle: BGBl. I S. 250, Anlage zu § 3 Absatz 1
schule:
titel: Rahmenlehrplan für die Ausbildungsberufe der Informations- und Telekommunikationstechnik
kurz: KMK-Rahmenlehrplan
vom: 2019-12-13
berufsbildpositionen:
# --- Abschnitt A der Verordnung: fachrichtungsübergreifend ---------------
- id: A1
gruppe: fachrichtungsuebergreifend
titel: Planen, Vorbereiten und Durchführen von Arbeitsaufgaben in Abstimmung mit kundenspezifischen Geschäfts- und Leistungsprozessen
kurz: Arbeitsaufgaben planen und durchführen
verordnung: Abschnitt A, lfd. Nr. 1
- id: A2
gruppe: fachrichtungsuebergreifend
titel: Informieren und Beraten von Kunden und Kundinnen
kurz: Kunden informieren und beraten
verordnung: Abschnitt A, lfd. Nr. 2
- id: A3
gruppe: fachrichtungsuebergreifend
titel: Beurteilen marktgängiger IT-Systeme und kundenspezifischer Lösungen
kurz: IT-Systeme beurteilen
verordnung: Abschnitt A, lfd. Nr. 3
- id: A4
gruppe: fachrichtungsuebergreifend
titel: Entwickeln, Erstellen und Betreuen von IT-Lösungen
kurz: IT-Lösungen entwickeln und betreuen
verordnung: Abschnitt A, lfd. Nr. 4
- id: A5
gruppe: fachrichtungsuebergreifend
titel: Durchführen und Dokumentieren von qualitätssichernden Maßnahmen
kurz: Qualität sichern und dokumentieren
verordnung: Abschnitt A, lfd. Nr. 5
- id: A6
gruppe: fachrichtungsuebergreifend
titel: Umsetzen, Integrieren und Prüfen von Maßnahmen zur IT-Sicherheit und zum Datenschutz
kurz: IT-Sicherheit und Datenschutz
verordnung: Abschnitt A, lfd. Nr. 6
- id: A7
gruppe: fachrichtungsuebergreifend
titel: Erbringen der Leistungen und Auftragsabschluss
kurz: Leistung erbringen und Auftrag abschließen
verordnung: Abschnitt A, lfd. Nr. 7
- id: A8
gruppe: fachrichtungsuebergreifend
titel: Betreiben von IT-Systemen
kurz: IT-Systeme betreiben
verordnung: Abschnitt A, lfd. Nr. 8
- id: A9
gruppe: fachrichtungsuebergreifend
titel: Inbetriebnehmen von Speicherlösungen
kurz: Speicherlösungen in Betrieb nehmen
verordnung: Abschnitt A, lfd. Nr. 9
- id: A10
gruppe: fachrichtungsuebergreifend
titel: Programmieren von Softwarelösungen
kurz: Softwarelösungen programmieren
verordnung: Abschnitt A, lfd. Nr. 10
# --- Abschnitt C der Verordnung: Fachrichtung Systemintegration ----------
- id: B1
gruppe: systemintegration
titel: Konzipieren und Realisieren von IT-Systemen
kurz: IT-Systeme konzipieren und realisieren
verordnung: Abschnitt C, lfd. Nr. 1
- id: B2
gruppe: systemintegration
titel: Installieren und Konfigurieren von Netzwerken
kurz: Netzwerke installieren und konfigurieren
verordnung: Abschnitt C, lfd. Nr. 2
- id: B3
gruppe: systemintegration
titel: Administrieren von IT-Systemen
kurz: IT-Systeme administrieren
verordnung: Abschnitt C, lfd. Nr. 3
# --- Abschnitt F der Verordnung: integrativ zu vermitteln ----------------
- id: C1
gruppe: integrativ
titel: Berufsbildung sowie Arbeits- und Tarifrecht
kurz: Berufsbildung, Arbeits- und Tarifrecht
verordnung: Abschnitt F, lfd. Nr. 1
- id: C2
gruppe: integrativ
titel: Aufbau und Organisation des Ausbildungsbetriebes
kurz: Aufbau und Organisation des Betriebes
verordnung: Abschnitt F, lfd. Nr. 2
- id: C3
gruppe: integrativ
titel: Sicherheit und Gesundheitsschutz bei der Arbeit
kurz: Sicherheit und Gesundheitsschutz
verordnung: Abschnitt F, lfd. Nr. 3
- id: C4
gruppe: integrativ
titel: Umweltschutz und Nachhaltigkeit
kurz: Umweltschutz und Nachhaltigkeit
verordnung: Abschnitt F, lfd. Nr. 4
- id: C5
gruppe: integrativ
titel: Vernetztes Zusammenarbeiten unter Nutzung digitaler Medien
kurz: Vernetztes Zusammenarbeiten
verordnung: Abschnitt F, lfd. Nr. 5
gruppen:
fachrichtungsuebergreifend: Fachrichtungsübergreifende berufsprofilgebende Fertigkeiten
systemintegration: Fachrichtung Systemintegration
integrativ: Integrativ zu vermittelnde Fertigkeiten
lernfelder:
- id: LF1
titel: Das Unternehmen und die eigene Rolle im Betrieb beschreiben
jahr: 1
stunden: 40
- id: LF2
titel: Arbeitsplätze nach Kundenwunsch ausstatten
jahr: 1
stunden: 80
- id: LF3
titel: Clients in Netzwerke einbinden
jahr: 1
stunden: 80
- id: LF4
titel: Schutzbedarfsanalyse im eigenen Arbeitsbereich durchführen
jahr: 1
stunden: 40
- id: LF5
titel: Software zur Verwaltung von Daten anpassen
jahr: 1
stunden: 80
- id: LF6
titel: Serviceanfragen bearbeiten
jahr: 2
stunden: 40
- id: LF7
titel: Cyber-physische Systeme ergänzen
jahr: 2
stunden: 80
- id: LF8
titel: Daten systemübergreifend bereitstellen
jahr: 2
stunden: 80
- id: LF9
titel: Netzwerke und Dienste bereitstellen
jahr: 2
stunden: 80
- id: LF10
titel: Serverdienste bereitstellen und Administrationsaufgaben automatisieren
jahr: 3
stunden: 80
- id: LF11
titel: Betrieb und Sicherheit vernetzter Systeme gewährleisten
jahr: 3
stunden: 80
- id: LF12
titel: Kundenspezifische Systemintegration durchführen
jahr: 3
stunden: 120
# Themenblöcke – die Klammer, unter der Handouts und Merkkarten gruppiert
# werden. Das Kürzel eines Handouts beginnt mit dem Buchstaben des Blocks.
bloecke:
- id: arbeitsalltag
kuerzel: A
titel: Arbeitsalltag und Auftrag
farbe: petrol
- id: hardware
kuerzel: H
titel: Hardware
farbe: petrol
- id: netzwerk
kuerzel: N
titel: Netzwerk
farbe: petrol
- id: system
kuerzel: S
titel: Betriebssysteme und Dienste
farbe: petrol
+29
View File
@@ -0,0 +1,29 @@
nummer: 1
titel: Arbeitsalltag und Auftrag
block: arbeitsalltag
leitfrage: Woran erkennst du, dass ein Auftrag verstanden ist – und nicht nur angenommen?
ziel: >-
Du kannst eine Störungsmeldung so aufnehmen und dokumentieren, dass ein
Kollege sie ohne Rückfrage übernehmen könnte.
handouts: [A1, A2, A3]
aufgaben: ["1.1"]
checklisten: [01-arbeitsplatz-uebergeben]
lernzielkontrolle: woche-01
tage:
- tag: Montag
theorie: [A1]
praxis: Ticketsystem einrichten, die letzten zwanzig geschlossenen Tickets lesen und drei davon nach Qualität bewerten.
- tag: Dienstag
theorie: [A2]
praxis: Beim Ausbilder in zwei Anrufen mithören, danach einen selbst annehmen und das Ticket schreiben.
- tag: Mittwoch
theorie: [A3]
praxis: Für drei offene Tickets eine Aufwandsschätzung erstellen und mit dem Ausbilder abgleichen.
- tag: Donnerstag
theorie: []
praxis: Praxisaufgabe 1.1 – einen Arbeitsplatz übernehmen, dokumentieren und übergeben.
- tag: Freitag
theorie: []
praxis: Lernzielkontrolle, danach Wochenrückblick und Berichtsheft schreiben.
+29
View File
@@ -0,0 +1,29 @@
nummer: 2
titel: Hardware beurteilen und beschaffen
block: hardware
leitfrage: Warum ist das billigste Gerät selten das günstigste?
ziel: >-
Du kannst einen Rechner öffnen, die Baugruppen benennen, den Zustand eines
Datenträgers beurteilen und eine Beschaffungsempfehlung begründen.
handouts: [H1, H2, H3]
aufgaben: ["2.1", "2.2"]
checklisten: [04-rechner-zerlegen]
lernzielkontrolle: null
tage:
- tag: Montag
theorie: [H1]
praxis: Zwei ausgemusterte Rechner zerlegen und wieder zusammenbauen, Baugruppen benennen.
- tag: Dienstag
theorie: [H2]
praxis: SMART-Werte von fünf Datenträgern aus dem Lager auslesen und bewerten.
- tag: Mittwoch
theorie: [H3]
praxis: Kriterien für die Notebookbeschaffung im Außendienst festlegen und gewichten.
- tag: Donnerstag
theorie: []
praxis: Praxisaufgabe 2.2 – drei Notebooks vergleichen und eine Empfehlung schreiben.
- tag: Freitag
theorie: []
praxis: Empfehlung dem Ausbilder vorstellen, Rückfragen beantworten, Berichtsheft schreiben.
+29
View File
@@ -0,0 +1,29 @@
nummer: 3
titel: Netzwerkgrundlagen
block: netzwerk
leitfrage: Wo im Netz suchst du zuerst – und warum nie oben?
ziel: >-
Du kannst Netz, Broadcast und Hostbereich zu einer Adresse bestimmen, einen
Netzwerkfehler schichtweise eingrenzen und DHCP- von DNS-Problemen unterscheiden.
handouts: [N1, N2, N3, N4]
aufgaben: ["3.1"]
checklisten: [03-client-ins-netz-bringen]
lernzielkontrolle: woche-03
tage:
- tag: Montag
theorie: [N1]
praxis: Netzplan des Betriebs abzeichnen, Geräte den Schichten zuordnen.
- tag: Dienstag
theorie: [N2]
praxis: Adressen und Masken der eigenen Netze aufnehmen, Netzadressen bestimmen.
- tag: Mittwoch
theorie: [N3]
praxis: Zwanzig Subnetting-Aufgaben rechnen, davon zehn ohne Tabelle.
- tag: Donnerstag
theorie: [N4]
praxis: Praxisaufgabe 3.1 – vier gestörte Clients ins Netz bringen und den Weg dokumentieren.
- tag: Freitag
theorie: []
praxis: Lernzielkontrolle, danach Berichtsheft schreiben.
+29
View File
@@ -0,0 +1,29 @@
nummer: 4
titel: Betriebssystem, Konten und Rechte
block: system
leitfrage: Wer darf was – und woran sieht man das in zwei Jahren noch?
ziel: >-
Du kannst Windows übergabefertig installieren und Berechtigungen so vergeben,
dass sie nachvollziehbar und pflegbar bleiben.
handouts: [S1, S2]
aufgaben: []
checklisten: [02-windows-installieren]
lernzielkontrolle: null
tage:
- tag: Montag
theorie: [S1]
praxis: Installationsmedium erstellen, Testrechner nach Checkliste neu aufsetzen.
- tag: Dienstag
theorie: []
praxis: Treiber, Updates und Fachanwendungen einspielen, Übergabeprotokoll ausfüllen.
- tag: Mittwoch
theorie: [S2]
praxis: Gruppenstruktur einer Beispielabteilung entwerfen und im Testsystem anlegen.
- tag: Donnerstag
theorie: []
praxis: Berechtigungen auf drei Testablagen setzen und mit zwei Testkonten prüfen.
- tag: Freitag
theorie: []
praxis: Monatliches Feedbackgespräch, danach Berichtsheft schreiben.
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

-109
View File
@@ -11,7 +11,6 @@
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"next": "^15.5.4", "next": "^15.5.4",
"pdfkit": "^0.17.1", "pdfkit": "^0.17.1",
@@ -4683,19 +4682,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -4771,18 +4757,6 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5153,43 +5127,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/gray-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -5529,15 +5466,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5924,15 +5852,6 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/language-subtag-registry": { "node_modules/language-subtag-registry": {
"version": "0.3.23", "version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@@ -7104,19 +7023,6 @@
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -7394,12 +7300,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7568,15 +7468,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
-1
View File
@@ -25,7 +25,6 @@
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"next": "^15.5.4", "next": "^15.5.4",
"pdfkit": "^0.17.1", "pdfkit": "^0.17.1",
+57
View File
@@ -0,0 +1,57 @@
/**
* Prüft alle Dateien unter content/, ohne die Anwendung zu starten.
*
* npm run inhalte:pruefen
*
* Endet mit 0, wenn alles in Ordnung ist, sonst mit 1 – so lässt sich das
* Skript in einen Prüflauf oder einen Git-Haken einhängen.
*/
import { ladeInhalte } from '../src/lib/inhalte/laden';
import { InhaltsFehler } from '../src/lib/inhalte/fehler';
import { inhaltWurzel } from '../src/lib/pfade';
function main() {
try {
const alle = ladeInhalte(inhaltWurzel);
const kartenJeBlock = alle.bloecke.map((b) => {
const anzahl = alle.karten.filter((k) => k.block === b.id).length;
return `${b.titel}: ${anzahl}`;
});
console.log('Inhalte in Ordnung.');
console.log('');
console.log(` Wochen ${alle.wochen.length}`);
console.log(` Handouts ${alle.handouts.length}`);
console.log(` Merkkarten ${alle.karten.length} (${kartenJeBlock.join(', ')})`);
console.log(` Praxisaufgaben ${alle.aufgaben.length}`);
console.log(` Checklisten ${alle.checklisten.length}`);
console.log(` Lernzielkontrollen ${alle.kontrollen.length}`);
console.log(` Glossareinträge ${alle.glossar.length}`);
console.log(` Berufsbildpositionen ${alle.positionen.length}`);
console.log(` Lernfelder ${alle.lernfelder.length}`);
console.log('');
const ohneAbdeckung = alle.positionen.filter(
(p) =>
!alle.handouts.some((h) => h.rahmenplan.includes(p.id)) &&
!alle.aufgaben.some((a) => a.rahmenplan.includes(p.id)),
);
if (ohneAbdeckung.length > 0) {
console.log(` Hinweis: ${ohneAbdeckung.length} Berufsbildpositionen sind von keinem Inhalt berührt:`);
console.log(` ${ohneAbdeckung.map((p) => p.id).join(', ')}`);
console.log(' Das ist kein Fehler – nur etwas, das im Laufe der Ausbildung geschlossen werden sollte.');
console.log('');
}
process.exit(0);
} catch (fehler) {
if (fehler instanceof InhaltsFehler) {
console.error(fehler.message);
process.exit(1);
}
throw fehler;
}
}
main();
+131
View File
@@ -0,0 +1,131 @@
/**
* Nimmt die Bildschirmfotos für die Anleitung auf.
*
* npm run build && npm run start (in einer zweiten Konsole)
* npm run screenshots
*
* Die Bilder landen unter docs/bilder/ und werden bei jedem Lauf überschrieben.
* So veralten sie nicht – wer die Oberfläche ändert, lässt das Skript neu laufen.
*
* Umgebungsvariablen:
* LERNBAR_BASIS Adresse der laufenden Anwendung (Vorgabe http://localhost:3000)
* LERNBAR_AUSBILDER E-Mail des Ausbilderkontos
* LERNBAR_AZUBI E-Mail des Azubikontos
* LERNBAR_PASSWORT_* zugehörige Passwörter
*/
import fs from 'node:fs';
import path from 'node:path';
import { chromium, type Page } from '@playwright/test';
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const ZIEL = path.join(process.cwd(), 'docs', 'bilder');
const AUSBILDER = {
email: process.env.LERNBAR_AUSBILDER ?? 'ausbilder@betrieb.de',
passwort: process.env.LERNBAR_PASSWORT_AUSBILDER ?? 'ausbilder-start',
};
const AZUBI = {
email: process.env.LERNBAR_AZUBI ?? 'azubi@betrieb.de',
passwort: process.env.LERNBAR_PASSWORT_AZUBI ?? 'azubi-start-2026',
};
const SCHREIBTISCH = { width: 1280, height: 900 };
const HANDY = { width: 390, height: 844 };
async function anmelden(seite: Page, konto: { email: string; passwort: string }) {
await seite.goto(`${BASIS}/anmelden`);
await seite.waitForSelector('#email');
await seite.fill('#email', konto.email);
await seite.fill('#passwort', konto.passwort);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
}
async function abmelden(seite: Page) {
await seite.click('button:has-text("Abmelden")');
await seite.waitForURL(/anmelden/);
}
async function bild(seite: Page, name: string, ganzeSeite = true) {
const datei = path.join(ZIEL, `${name}.png`);
await seite.screenshot({ path: datei, fullPage: ganzeSeite });
console.log(` ${path.relative(process.cwd(), datei)}`);
}
async function main() {
fs.mkdirSync(ZIEL, { recursive: true });
const browser = await chromium.launch();
const umgebung = await browser.newContext({ viewport: SCHREIBTISCH, locale: 'de-DE', colorScheme: 'light' });
const seite = await umgebung.newPage();
console.log('Bildschirmfotos werden aufgenommen …');
// --- Anmeldung ---
await seite.goto(`${BASIS}/anmelden`);
await seite.waitForSelector('#email');
await bild(seite, '01-anmelden');
// --- Ausbilder ---
await anmelden(seite, AUSBILDER);
await bild(seite, '02-ausbilder-ueberblick');
await seite.goto(`${BASIS}/verwaltung`);
await seite.waitForSelector('#neu-name');
await bild(seite, '03-verwaltung');
await abmelden(seite);
// --- Azubi ---
await anmelden(seite, AZUBI);
await bild(seite, '10-azubi-start');
await seite.goto(`${BASIS}/handouts`);
await seite.waitForSelector('h1');
await bild(seite, '11-handouts-liste');
await seite.goto(`${BASIS}/handouts?suche=subnetting`);
await seite.waitForSelector('h2');
await bild(seite, '12-handouts-suche');
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('h1');
await seite.click('.pruefungsfrage > summary');
await bild(seite, '13-handout-n3');
await seite.goto(`${BASIS}/glossar`);
await seite.waitForSelector('h1');
await bild(seite, '14-glossar');
// --- Dunkle Darstellung ---
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('button[aria-pressed]');
await seite.click('button[aria-pressed="false"]');
await seite.waitForTimeout(200);
await bild(seite, '20-dunkel');
await seite.click('button[aria-pressed="true"]');
// --- Handy ---
const handy = await browser.newContext({
viewport: HANDY,
locale: 'de-DE',
isMobile: true,
hasTouch: true,
deviceScaleFactor: 2,
});
const handySeite = await handy.newPage();
await anmelden(handySeite, AZUBI);
await handySeite.goto(`${BASIS}/handouts/N3`);
await handySeite.waitForSelector('h1');
await bild(handySeite, '30-handy-handout');
await browser.close();
console.log('Fertig.');
}
main().catch((fehler) => {
console.error('Die Bildschirmfotos konnten nicht aufgenommen werden.');
console.error(fehler instanceof Error ? fehler.message : fehler);
console.error(`Läuft die Anwendung unter ${BASIS}? Stimmen LERNBAR_AUSBILDER und LERNBAR_AZUBI?`);
process.exit(1);
});
@@ -0,0 +1,38 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
export function GlossarSuche({ anfangswert }: { anfangswert: string }) {
const [wert, setzeWert] = useState(anfangswert);
const router = useRouter();
const erstesMal = useRef(true);
useEffect(() => {
if (erstesMal.current) {
erstesMal.current = false;
return;
}
const zeitgeber = setTimeout(() => {
router.replace(wert.trim() ? `/glossar?suche=${encodeURIComponent(wert.trim())}` : '/glossar', { scroll: false });
}, 200);
return () => clearTimeout(zeitgeber);
}, [wert, router]);
return (
<form action="/glossar" method="get" role="search" className="nicht-drucken">
<label htmlFor="glossar-suche" className="nur-lesehilfe">
Glossar durchsuchen
</label>
<input
id="glossar-suche"
name="suche"
type="search"
placeholder="Begriff suchen – z. B. Subnetzmaske"
value={wert}
onChange={(e) => setzeWert(e.target.value)}
autoComplete="off"
/>
</form>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
import { normalisiere } from '@/lib/inhalte/laden';
import { GlossarSuche } from './GlossarSuche';
export const metadata: Metadata = { title: 'Glossar' };
export const dynamic = 'force-dynamic';
export default async function GlossarSeite({ searchParams }: { searchParams: Promise<{ suche?: string }> }) {
await erfordereBenutzer();
const parameter = await searchParams;
const alle = inhalte();
const suchbegriff = (parameter.suche ?? '').trim();
const gesucht = normalisiere(suchbegriff);
const eintraege = [...alle.glossar].sort((a, b) => a.begriff.localeCompare(b.begriff, 'de-DE'));
const gefiltert = gesucht
? eintraege.filter(
(e) =>
normalisiere(e.begriff).includes(gesucht) ||
normalisiere(e.steht_fuer ?? '').includes(gesucht) ||
normalisiere(e.erklaerung).includes(gesucht) ||
e.formen.some((f) => normalisiere(f).includes(gesucht)),
)
: eintraege;
return (
<div className="stapel">
<div>
<h1>Glossar</h1>
<p className="leise">
{alle.glossar.length} Begriffe. In den Handouts sind sie verlinkt – ein Klick führt hierher.
</p>
</div>
<GlossarSuche anfangswert={suchbegriff} />
{gefiltert.length === 0 ? (
<p className="karte">Kein Begriff passt zu &bdquo;{suchbegriff}&ldquo;.</p>
) : (
<dl className="glossar">
{gefiltert.map((eintrag) => (
<div key={eintrag.begriff} id={glossarAnker(eintrag.begriff)} className="glossar-eintrag karte">
<dt>
{eintrag.begriff}
{eintrag.steht_fuer ? <span className="glossar-langform">{eintrag.steht_fuer}</span> : null}
</dt>
<dd>
<p style={{ marginBottom: eintrag.siehe_auch.length > 0 ? '0.5rem' : 0 }}>{eintrag.erklaerung}</p>
{eintrag.siehe_auch.length > 0 ? (
<p className="leise" style={{ margin: 0 }}>
Siehe auch:{' '}
{eintrag.siehe_auch.map((verweis, index) => (
<span key={verweis}>
{index > 0 ? ', ' : ''}
<Link href={`#${glossarAnker(verweis)}`}>{verweis}</Link>
</span>
))}
</p>
) : null}
</dd>
</div>
))}
</dl>
)}
</div>
);
}
@@ -0,0 +1,51 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
/**
* Suchfeld für die Handouts. Sucht beim Tippen mit kurzer Verzögerung, damit
* nicht bei jedem Buchstaben eine Anfrage losgeht, und funktioniert auch ohne
* JavaScript – dann eben erst beim Absenden.
*/
export function Suchfeld({ anfangswert }: { anfangswert: string }) {
const [wert, setzeWert] = useState(anfangswert);
const router = useRouter();
const erstesMal = useRef(true);
useEffect(() => {
if (erstesMal.current) {
erstesMal.current = false;
return;
}
const zeitgeber = setTimeout(() => {
const ziel = wert.trim() ? `/handouts?suche=${encodeURIComponent(wert.trim())}` : '/handouts';
router.replace(ziel, { scroll: false });
}, 250);
return () => clearTimeout(zeitgeber);
}, [wert, router]);
return (
<form action="/handouts" method="get" role="search" className="nicht-drucken">
<label htmlFor="suche" className="nur-lesehilfe">
Handouts durchsuchen
</label>
<div className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<input
id="suche"
name="suche"
type="search"
placeholder="Suchen – z. B. Subnetting, SMART, Vererbung"
value={wert}
onChange={(e) => setzeWert(e.target.value)}
autoComplete="off"
/>
<button type="submit" className="knopf knopf-zweit" style={{ flexShrink: 0 }}>
Suchen
</button>
</div>
</form>
);
}
@@ -0,0 +1,29 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { alsGelesenMerken, alsUngelesenMerken } from '@/lib/repos/fortschritt';
/**
* Gelesen-Markierung umschalten. Nur der Azubi führt seinen eigenen Fortschritt –
* der Ausbilder liest mit, hakt aber nichts für ihn ab.
*/
export async function gelesenUmschalten(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const handoutId = String(formular.get('handoutId') ?? '');
if (!inhalte().handoutNach.has(handoutId)) return;
const gelesen = String(formular.get('gelesen') ?? '') === '1';
if (gelesen) {
alsGelesenMerken(benutzer.id, handoutId);
} else {
alsUngelesenMerken(benutzer.id, handoutId);
}
revalidatePath(`/handouts/${handoutId}`);
revalidatePath('/handouts');
revalidatePath('/');
}
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const handout = inhalte().handoutNach.get(id.toUpperCase());
return { title: handout ? `${handout.id} – ${handout.titel}` : 'Handout' };
}
export default async function HandoutSeite({ params }: { params: Promise<{ id: string }> }) {
const benutzer = await erfordereBenutzer();
const { id } = await params;
const alle = inhalte();
const handout = alle.handoutNach.get(id.toUpperCase());
if (!handout) notFound();
const block = alle.blockNach.get(handout.block);
const lernfeld = alle.lernfeldNach.get(handout.lernfeld);
const positionen = handout.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
const woche = alle.wocheNach.get(handout.woche);
const istAzubi = benutzer.rolle === 'azubi';
const gelesen = istAzubi ? gelesenAm(benutzer.id, handout.id) : null;
const desBlocks = alle.handoutsNachBlock.get(handout.block) ?? [];
const stelle = desBlocks.findIndex((h) => h.id === handout.id);
const vorher = stelle > 0 ? desBlocks[stelle - 1] : undefined;
const nachher = stelle < desBlocks.length - 1 ? desBlocks[stelle + 1] : undefined;
const wesentlichHtml = handout.wesentlich ? verlinkeGlossar(handout.wesentlich.html, alle.glossar) : '';
return (
<article className="stapel handout">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/handouts">Handouts</Link>
{block ? (
<>
{' › '}
<Link href={`/handouts?block=${block.id}`}>{block.titel}</Link>
</>
) : null}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="handout-kuerzel handout-kuerzel-gross">{handout.id}</span>
{gelesen ? (
<span className="marke-status" data-ton="gut">
✓ gelesen am {alsZeitpunktText(gelesen)}
</span>
) : null}
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{handout.titel}</h1>
<p className="handout-worum">{handout.worum}</p>
</header>
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.length === 0
? '–'
: positionen.map((p) => (
<span key={p.id} title={`${p.titel} (${p.verordnung})`} className="zuordnung-marke">
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
<div>
<dt>Lernfeld</dt>
<dd>
{lernfeld ? (
<span className="zuordnung-marke" title={`${lernfeld.stunden} Stunden im ${lernfeld.jahr}. Jahr`}>
<strong>{lernfeld.id}</strong> {lernfeld.titel}
</span>
) : (
'–'
)}
</dd>
</div>
<div>
<dt>Woche</dt>
<dd>
{woche ? (
<span className="zuordnung-marke">
<strong>{woche.nummer}</strong> {woche.titel}
</span>
) : (
'–'
)}
</dd>
</div>
</dl>
</div>
{handout.wesentlich ? (
<section aria-labelledby="wesentlich">
<h2 id="wesentlich">Das Wesentliche</h2>
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: wesentlichHtml }} />
</section>
) : null}
{handout.uebersicht ? (
<section aria-labelledby="uebersicht">
<h2 id="uebersicht">Übersicht</h2>
<div className="tabelle-huelle fliesstext" dangerouslySetInnerHTML={{ __html: handout.uebersicht.html }} />
</section>
) : null}
<aside className="merksatz" aria-label="Merksatz">
<span className="merksatz-kennzeichnung">Merksatz</span>
<p>{handout.merksatz}</p>
</aside>
{handout.pruefungsfrage ? (
<details className="pruefungsfrage">
<summary>Prüfungsfrage anzeigen</summary>
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: handout.pruefungsfrage.html }} />
</details>
) : null}
<section aria-labelledby="karten" className="karte stapel-eng">
<h2 id="karten" style={{ marginBottom: '0.3rem' }}>
Merkkarten zu diesem Handout
</h2>
<p className="leise" style={{ margin: 0 }}>
{handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie
nach Fälligkeit auf.
</p>
<ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
{handout.karten.map((karte, index) => (
<li key={index}>
<strong>{karte.frage}</strong>
<br />
<span className="leise">{karte.antwort}</span>
</li>
))}
</ol>
</section>
{istAzubi ? (
<form action={gelesenUmschalten} className="nicht-drucken">
<input type="hidden" name="handoutId" value={handout.id} />
<input type="hidden" name="gelesen" value={gelesen ? '0' : '1'} />
<button type="submit" className={`knopf ${gelesen ? 'knopf-zweit' : 'knopf-akzent'}`}>
{gelesen ? 'Doch noch nicht gelesen' : 'Als gelesen markieren'}
</button>
</form>
) : null}
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
{vorher ? (
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
← {vorher.id} {vorher.titel}
</Link>
) : (
<span />
)}
{nachher ? (
<Link href={`/handouts/${nachher.id}`} className="knopf knopf-still">
{nachher.id} {nachher.titel} →
</Link>
) : (
<span />
)}
</nav>
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>{handout.datei}</code>
</p>
</article>
);
}
+144
View File
@@ -0,0 +1,144 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { sucheHandouts } from '@/lib/inhalte/suche';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { Suchfeld } from './Suchfeld';
export const metadata: Metadata = { title: 'Handouts' };
export const dynamic = 'force-dynamic';
export default async function HandoutListe({
searchParams,
}: {
searchParams: Promise<{ suche?: string; block?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const parameter = await searchParams;
const alle = inhalte();
const gelesen = benutzer.rolle === 'azubi' ? gelesenSet(benutzer.id) : new Set<string>();
const suchbegriff = (parameter.suche ?? '').trim();
const treffer = suchbegriff ? sucheHandouts(alle, suchbegriff) : null;
const gefilterterBlock = parameter.block && alle.blockNach.has(parameter.block) ? parameter.block : null;
return (
<div className="stapel">
<div>
<h1>Handouts</h1>
<p className="leise">
{alle.handouts.length} Lerneinheiten in {alle.bloecke.length} Themenblöcken
{benutzer.rolle === 'azubi' ? ` · ${gelesen.size} gelesen` : ''}
</p>
</div>
<Suchfeld anfangswert={suchbegriff} />
{treffer ? (
<section className="stapel" aria-labelledby="treffer-ueberschrift">
<h2 id="treffer-ueberschrift">
{treffer.length === 0
? `Nichts gefunden für „${suchbegriff}"`
: `${treffer.length} ${treffer.length === 1 ? 'Treffer' : 'Treffer'} für „${suchbegriff}"`}
</h2>
{treffer.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Versuch es mit einem einzelnen Wort. Gesucht wird in Titel, Merksatz, Merkkarten und im gesamten Text.
</p>
) : (
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{treffer.map(({ handout, ausschnitt }) => (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`} className="handout-treffer">
<span className="handout-kuerzel">{handout.id}</span>
<span>
<strong>{handout.titel}</strong>
<br />
<span className="leise">{ausschnitt}</span>
</span>
{gelesen.has(handout.id) ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : null}
</Link>
</li>
))}
</ul>
)}
</section>
) : (
<>
<nav className="reihe nicht-drucken" aria-label="Themenblöcke">
<Link href="/handouts" className={`knopf ${gefilterterBlock ? 'knopf-zweit' : ''}`} scroll={false}>
Alle
</Link>
{alle.bloecke.map((block) => (
<Link
key={block.id}
href={`/handouts?block=${block.id}`}
className={`knopf ${gefilterterBlock === block.id ? '' : 'knopf-zweit'}`}
scroll={false}
>
{block.titel}
</Link>
))}
</nav>
{alle.bloecke
.filter((block) => !gefilterterBlock || block.id === gefilterterBlock)
.map((block) => {
const desBlocks = alle.handoutsNachBlock.get(block.id) ?? [];
if (desBlocks.length === 0) return null;
const gelesenImBlock = desBlocks.filter((h) => gelesen.has(h.id)).length;
return (
<section key={block.id} className="stapel" aria-labelledby={`block-${block.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`block-${block.id}`} style={{ margin: 0 }}>
{block.titel}
</h2>
{benutzer.rolle === 'azubi' ? (
<span className="leise">
{gelesenImBlock} von {desBlocks.length} gelesen
</span>
) : (
<span className="leise">{desBlocks.length} Handouts</span>
)}
</div>
<ul className="raster" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{desBlocks.map((handout) => (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`} className="handout-kachel">
<span className="reihe" style={{ justifyContent: 'space-between', gap: '0.5rem' }}>
<span className="handout-kuerzel">{handout.id}</span>
{gelesen.has(handout.id) ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : (
<span className="leise" style={{ fontSize: '0.8rem' }}>
Woche {handout.woche}
</span>
)}
</span>
<strong style={{ display: 'block', marginTop: '0.4rem' }}>{handout.titel}</strong>
<span className="leise" style={{ display: 'block', marginTop: '0.2rem' }}>
{handout.worum}
</span>
</Link>
</li>
))}
</ul>
</section>
);
})}
</>
)}
</div>
);
}
+7 -1
View File
@@ -6,10 +6,16 @@ export const dynamic = 'force-dynamic';
// Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele, // Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele,
// die es auch wirklich gibt. // die es auch wirklich gibt.
const fuerAzubi: Navigationseintrag[] = [{ pfad: '/', titel: 'Woche' }]; const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' },
];
const fuerAusbilder: Navigationseintrag[] = [ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' }, { pfad: '/', titel: 'Überblick' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' }, { pfad: '/verwaltung', titel: 'Verwaltung' },
]; ];
+2 -1
View File
@@ -5,6 +5,7 @@ import { erfordereAusbilder } from '@/lib/auth/rechte';
import { passwortMangel } from '@/lib/auth/passwort'; import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer'; import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung'; import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { alsDatumText } from '@/lib/zeit';
export type Rueckmeldung = { fehler?: string; erfolg?: string }; export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -39,7 +40,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
}); });
revalidatePath('/verwaltung'); revalidatePath('/verwaltung');
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${beginn} eingerichtet.` }; return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` };
} }
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> { export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
+211 -3
View File
@@ -323,9 +323,7 @@ select:disabled {
color: #f2f8fa; color: #f2f8fa;
text-decoration: none; text-decoration: none;
letter-spacing: -0.01em; letter-spacing: -0.01em;
display: inline-flex; white-space: nowrap;
align-items: center;
gap: 0.5rem;
} }
.marke:hover { color: #fff; } .marke:hover { color: #fff; }
.marke .marke-akzent { color: #e5a552; } .marke .marke-akzent { color: #e5a552; }
@@ -488,3 +486,213 @@ table.tabelle th {
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
/* --- Handouts --- */
.handout-kuerzel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.6rem;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-klein);
background: var(--petrol);
color: var(--text-auf-petrol);
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.handout-kuerzel-gross {
font-size: 1rem;
padding: 0.25rem 0.7rem;
}
.handout-kachel {
display: block;
height: 100%;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.9rem 1rem;
text-decoration: none;
color: var(--text);
}
.handout-kachel:hover {
background: var(--flaeche-kraeftig);
border-color: var(--rand);
color: var(--text);
}
.handout-treffer {
display: flex;
gap: 0.75rem;
align-items: flex-start;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.8rem 1rem;
text-decoration: none;
color: var(--text);
}
.handout-treffer:hover {
background: var(--flaeche-kraeftig);
color: var(--text);
}
.handout-worum {
font-size: 1.05rem;
color: var(--text-leise);
margin: 0;
}
.handout-zuordnung dl {
margin: 0;
display: grid;
gap: 0.6rem;
}
.handout-zuordnung dt {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-leise);
font-weight: 650;
margin-bottom: 0.2rem;
}
.handout-zuordnung dd {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.zuordnung-marke {
display: inline-flex;
gap: 0.35rem;
align-items: baseline;
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
padding: 0.15rem 0.5rem;
font-size: 0.87rem;
}
.zuordnung-marke strong {
color: var(--petrol);
}
.fliesstext > *:first-child { margin-top: 0; }
.fliesstext > *:last-child { margin-bottom: 0; }
.fliesstext ul,
.fliesstext ol {
padding-left: 1.3rem;
margin: 0 0 1em;
}
.fliesstext li + li { margin-top: 0.4rem; }
.fliesstext table {
border-collapse: collapse;
width: 100%;
font-size: 0.94rem;
}
.fliesstext th,
.fliesstext td {
border: 1px solid var(--rand-zart);
padding: 0.45rem 0.65rem;
text-align: left;
vertical-align: top;
}
.fliesstext th {
background: var(--flaeche);
font-weight: 650;
}
.merksatz {
border-left: 5px solid var(--bernstein);
background: var(--warnung-flaeche);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 0.9rem 1.1rem;
}
.merksatz-kennzeichnung {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
color: var(--warnung);
margin-bottom: 0.2rem;
}
.merksatz p {
margin: 0;
font-size: 1.08rem;
font-weight: 600;
}
.pruefungsfrage {
border: 1px solid var(--rand);
border-radius: var(--radius);
background: var(--grund);
}
.pruefungsfrage > summary {
cursor: pointer;
padding: 0.7rem 1rem;
font-weight: 650;
list-style: none;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
}
.pruefungsfrage > summary::-webkit-details-marker { display: none; }
.pruefungsfrage > summary::before {
content: "▸";
color: var(--petrol);
font-size: 0.9rem;
}
.pruefungsfrage[open] > summary::before { content: "▾"; }
.pruefungsfrage[open] > summary { border-bottom: 1px solid var(--rand-zart); }
.pruefungsfrage > div { padding: 0.9rem 1rem; }
a.glossarbegriff {
color: inherit;
text-decoration: underline dotted var(--petrol);
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
cursor: help;
}
a.glossarbegriff:hover {
color: var(--petrol);
text-decoration-style: solid;
}
/* --- Glossar --- */
.glossar {
display: grid;
gap: 0.75rem;
margin: 0;
}
.glossar-eintrag {
scroll-margin-top: 1rem;
}
.glossar-eintrag dt {
font-weight: 700;
font-size: 1.05rem;
margin-bottom: 0.3rem;
}
.glossar-langform {
display: block;
font-weight: 400;
font-size: 0.85rem;
color: var(--text-leise);
}
.glossar-eintrag dd { margin: 0; }
.glossar-eintrag:target {
outline: 3px solid var(--bernstein);
outline-offset: 2px;
}
@media print {
.pruefungsfrage > div { display: block !important; }
.handout-zuordnung { border: 1px solid #999; }
a.glossarbegriff { text-decoration: none; }
}
+2
View File
@@ -5,6 +5,8 @@ export const metadata: Metadata = {
title: { default: 'Lern-Bar', template: '%s · Lern-Bar' }, title: { default: 'Lern-Bar', template: '%s · Lern-Bar' },
description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration', description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration',
applicationName: 'Lern-Bar', applicationName: 'Lern-Bar',
// Sonst macht der Browser aus Zahlenkolonnen in Tabellen Telefonnummern.
formatDetection: { telephone: false, date: false, address: false, email: false },
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
+8 -1
View File
@@ -8,6 +8,13 @@ export async function register() {
const { rohVerbindung } = await import('./lib/db'); const { rohVerbindung } = await import('./lib/db');
rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an
console.log('[lernbar] Datenbank bereit.'); console.log('[lernbar] Datenbank bereit.');
const { inhalteEinlesen } = await import('./lib/inhalte');
const alle = inhalteEinlesen();
console.log(
`[lernbar] Inhalte gelesen: ${alle.wochen.length} Wochen, ${alle.handouts.length} Handouts, ` +
`${alle.karten.length} Merkkarten, ${alle.aufgaben.length} Aufgaben, ${alle.checklisten.length} Checklisten, ` +
`${alle.kontrollen.length} Lernzielkontrollen, ${alle.glossar.length} Glossareinträge.`,
);
} }
+13
View File
@@ -0,0 +1,13 @@
-- Baustein 2: gelesene Handouts.
-- Der Inhalt selbst liegt als Datei im Repository; hier steht nur, wer was
-- gelesen hat. Die Handout-Kennung ist deshalb ein freier Text ohne
-- Fremdschlüssel – die Prüfung, dass es sie gibt, macht der Inhaltslader.
CREATE TABLE handout_gelesen (
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
handout_id TEXT NOT NULL,
gelesen_am TEXT NOT NULL,
PRIMARY KEY (benutzer_id, handout_id)
);
CREATE INDEX handout_gelesen_benutzer_idx ON handout_gelesen (benutzer_id);
+15 -2
View File
@@ -1,4 +1,4 @@
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
/** Rollen – bewusst nur zwei. */ /** Rollen – bewusst nur zwei. */
export const rollen = ['ausbilder', 'azubi'] as const; export const rollen = ['ausbilder', 'azubi'] as const;
@@ -51,9 +51,22 @@ export const ausbildung = sqliteTable(
(t) => [uniqueIndex('ausbildung_azubi_idx').on(t.azubiId), index('ausbildung_ausbilder_idx').on(t.ausbilderId)], (t) => [uniqueIndex('ausbildung_azubi_idx').on(t.azubiId), index('ausbildung_ausbilder_idx').on(t.ausbilderId)],
); );
export const handoutGelesen = sqliteTable(
'handout_gelesen',
{
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
handoutId: text('handout_id').notNull(),
gelesenAm: text('gelesen_am').notNull(),
},
(t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)],
);
export type Benutzer = typeof benutzer.$inferSelect; export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect; export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect; export type Ausbildung = typeof ausbildung.$inferSelect;
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */ /** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = { benutzer, sitzung, ausbildung }; export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen };
+73
View File
@@ -0,0 +1,73 @@
/**
* Fehler beim Einlesen der Inhalte.
*
* Der Anspruch: Wer eine Inhaltsdatei kaputt macht, bekommt Datei, Zeile und
* einen Satz, der sagt, was zu tun ist – nicht einen Stapelabzug.
*/
export type Fundstelle = {
datei: string;
zeile?: number;
spalte?: number;
feld?: string;
};
export type Einzelfehler = Fundstelle & { meldung: string };
export class InhaltsFehler extends Error {
readonly fehler: Einzelfehler[];
constructor(fehler: Einzelfehler[]) {
super(InhaltsFehler.formatiere(fehler));
this.name = 'InhaltsFehler';
this.fehler = fehler;
}
static formatiere(fehler: Einzelfehler[]): string {
const zeilen = fehler.map((f) => {
const ort = [f.datei, f.zeile, f.spalte].filter((t) => t !== undefined).join(':');
const feld = f.feld ? ` (${f.feld})` : '';
return ` ${ort}${feld}\n ${f.meldung}`;
});
const anzahl = fehler.length === 1 ? '1 Fehler' : `${fehler.length} Fehler`;
return [
'',
`Die Inhalte unter content/ sind nicht in Ordnung – ${anzahl}:`,
'',
...zeilen,
'',
'Die Anwendung startet erst, wenn alle Inhalte gültig sind.',
'Zum Prüfen ohne Start: npm run inhalte:pruefen',
'',
].join('\n');
}
}
/** Sammelt Fehler, statt beim ersten abzubrechen – so sieht man alle auf einmal. */
export class Fehlersammler {
private readonly gesammelt: Einzelfehler[] = [];
melde(fehler: Einzelfehler): void {
this.gesammelt.push(fehler);
}
get anzahl(): number {
return this.gesammelt.length;
}
get leer(): boolean {
return this.gesammelt.length === 0;
}
alle(): Einzelfehler[] {
return [...this.gesammelt];
}
/** Wirft, sobald mindestens ein Fehler gesammelt wurde. */
wirfWennNoetig(): void {
if (this.gesammelt.length > 0) {
throw new InhaltsFehler(this.gesammelt);
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { GlossarEintrag } from './schema';
/**
* Verlinkt Glossarbegriffe im fertigen HTML eines Handouts.
*
* Bewusst zurückhaltend: jeder Begriff höchstens einmal je Seite, nie innerhalb
* von Code, Überschriften oder bestehenden Verweisen. Ein Text, in dem jedes
* zweite Wort unterstrichen ist, liest sich schlechter als einer ganz ohne.
*
* Der Verweis ist ein echter Link auf das Glossar und trägt die Erklärung
* zusätzlich im title-Attribut – reines Überfahren mit der Maus wäre auf dem
* Handy und mit der Tastatur nicht bedienbar.
*/
const gemiedeneElemente = new Set(['code', 'pre', 'a', 'h1', 'h2', 'h3', 'h4', 'th']);
export function glossarAnker(begriff: string): string {
return begriff
.toLocaleLowerCase('de-DE')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function maskiere(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fuerRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
type Suchmuster = { muster: RegExp; eintrag: GlossarEintrag };
/** Baut die Suchmuster einmal auf – Begriff und alle angegebenen Formen. */
export function musterFuer(glossar: GlossarEintrag[]): Suchmuster[] {
const muster: Suchmuster[] = [];
for (const eintrag of glossar) {
const formen = [eintrag.begriff, ...eintrag.formen];
for (const form of formen) {
muster.push({
// Wortgrenzen von Hand, weil \b bei Umlauten und Bindestrichen daneben liegt.
muster: new RegExp(`(^|[^\\p{L}\\p{N}_-])(${fuerRegex(form)})(?=$|[^\\p{L}\\p{N}_-])`, 'iu'),
eintrag,
});
}
}
// Längere Formen zuerst, damit „MAC-Adresse" nicht als „Adresse" endet.
return muster.sort((a, b) => b.muster.source.length - a.muster.source.length);
}
export function verlinkeGlossar(html: string, glossar: GlossarEintrag[]): string {
if (glossar.length === 0) return html;
const muster = musterFuer(glossar);
const bereitsVerlinkt = new Set<string>();
const teile = html.split(/(<[^>]+>)/);
const offen: string[] = [];
for (let i = 0; i < teile.length; i += 1) {
const teil = teile[i]!;
if (teil.startsWith('<')) {
const treffer = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)/.exec(teil);
if (treffer) {
const [, schrägstrich, name] = treffer;
const kleinName = name!.toLowerCase();
if (schrägstrich) {
const stelle = offen.lastIndexOf(kleinName);
if (stelle !== -1) offen.splice(stelle, 1);
} else if (!teil.endsWith('/>')) {
offen.push(kleinName);
}
}
continue;
}
if (teil.trim() === '') continue;
if (offen.some((element) => gemiedeneElemente.has(element))) continue;
let text = teil;
for (const { muster: regex, eintrag } of muster) {
if (bereitsVerlinkt.has(eintrag.begriff)) continue;
const gefunden = regex.exec(text);
if (!gefunden) continue;
const erklaerung = eintrag.steht_fuer
? `${eintrag.steht_fuer} – ${eintrag.erklaerung}`
: eintrag.erklaerung;
text =
text.slice(0, gefunden.index) +
gefunden[1] +
`<a class="glossarbegriff" href="/glossar#${glossarAnker(eintrag.begriff)}" title="${maskiere(erklaerung)}">${gefunden[2]}</a>` +
text.slice(gefunden.index + gefunden[0].length);
bereitsVerlinkt.add(eintrag.begriff);
}
teile[i] = text;
}
return teile.join('');
}
+77
View File
@@ -0,0 +1,77 @@
import fs from 'node:fs';
import { inhaltWurzel } from '../pfade';
import { ladeInhalte, type Inhalte } from './laden';
/**
* Die Inhalte werden einmal eingelesen und liegen danach im Speicher.
*
* Im Entwicklungsbetrieb wird das Verzeichnis beobachtet: Ändert sich eine
* Datei, wird beim nächsten Zugriff neu eingelesen. Schlägt das Einlesen dabei
* fehl, bleibt der bisherige Stand stehen und der Fehler wird ins Protokoll
* geschrieben – im laufenden Entwicklungsbetrieb ist ein Absturz beim Tippen
* nicht hilfreich. Beim Start dagegen bricht die Anwendung ab.
*/
type Halter = {
inhalte?: Inhalte;
beobachter?: fs.FSWatcher;
veraltet?: boolean;
};
const halter = globalThis as unknown as { __lernbarInhalte?: Halter };
halter.__lernbarInhalte ??= {};
const imEntwicklungsbetrieb = process.env.NODE_ENV !== 'production';
function beobachte(): void {
const zustand = halter.__lernbarInhalte!;
if (zustand.beobachter || !imEntwicklungsbetrieb) return;
if (!fs.existsSync(inhaltWurzel)) return;
try {
zustand.beobachter = fs.watch(inhaltWurzel, { recursive: true }, () => {
zustand.veraltet = true;
});
zustand.beobachter.unref?.();
} catch {
// Auf manchen Dateisystemen gibt es kein rekursives Beobachten. Dann eben nicht.
}
}
/** Die eingelesenen Inhalte. Liest beim ersten Aufruf ein. */
export function inhalte(): Inhalte {
const zustand = halter.__lernbarInhalte!;
if (!zustand.inhalte) {
zustand.inhalte = ladeInhalte(inhaltWurzel);
beobachte();
return zustand.inhalte;
}
if (zustand.veraltet) {
zustand.veraltet = false;
try {
zustand.inhalte = ladeInhalte(inhaltWurzel);
console.log('[lernbar] Inhalte neu eingelesen.');
} catch (fehler) {
console.error(`[lernbar] Inhalte konnten nicht neu eingelesen werden – der bisherige Stand bleibt aktiv.`);
console.error((fehler as Error).message);
}
}
return zustand.inhalte;
}
/** Erzwingt das Einlesen – beim Start, damit Fehler sofort auffallen. */
export function inhalteEinlesen(): Inhalte {
const zustand = halter.__lernbarInhalte!;
zustand.inhalte = ladeInhalte(inhaltWurzel);
zustand.veraltet = false;
beobachte();
return zustand.inhalte;
}
export { ladeInhalte };
export type { Inhalte, Handout, Aufgabe, Merkkarte } from './laden';
export * from './schema';
export { InhaltsFehler } from './fehler';
+251
View File
@@ -0,0 +1,251 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ladeInhalte } from './laden';
import { InhaltsFehler } from './fehler';
/**
* Der Inhaltslader wird gegen ein winziges, vollständiges Beispielverzeichnis
* geprüft, das je Test gezielt kaputt gemacht wird. So lässt sich prüfen, ob
* die Meldung wirklich auf Datei und Zeile zeigt.
*/
const aufraeumen: string[] = [];
afterEach(() => {
while (aufraeumen.length > 0) {
fs.rmSync(aufraeumen.pop()!, { recursive: true, force: true });
}
});
const RAHMENPLAN = `quellen:
betrieb: { titel: FIAusbV, kurz: FIAusbV, vom: "2020-02-28", fundstelle: Anlage }
schule: { titel: Rahmenlehrplan, kurz: KMK, vom: "2019-12-13" }
berufsbildpositionen:
- { id: A1, gruppe: allgemein, titel: Arbeitsaufgaben planen, kurz: Planen, verordnung: "A, Nr. 1" }
- { id: B2, gruppe: si, titel: Netzwerke installieren, kurz: Netzwerke, verordnung: "C, Nr. 2" }
gruppen:
allgemein: Allgemein
si: Systemintegration
lernfelder:
- { id: LF3, titel: Clients in Netzwerke einbinden, jahr: 1, stunden: 80 }
bloecke:
- { id: netzwerk, kuerzel: N, titel: Netzwerk }
`;
const WOCHE = `nummer: 1
titel: Netzwerkgrundlagen
block: netzwerk
leitfrage: Wo suchst du zuerst?
ziel: Du kannst Netze bestimmen.
handouts: [N1]
aufgaben: []
checklisten: []
lernzielkontrolle: null
tage:
- { tag: Montag, theorie: [N1], praxis: Netzplan zeichnen }
- { tag: Dienstag, theorie: [], praxis: Adressen aufnehmen }
- { tag: Mittwoch, theorie: [], praxis: Rechnen }
- { tag: Donnerstag, theorie: [], praxis: Fehlersuche }
- { tag: Freitag, theorie: [], praxis: Rückblick }
`;
const HANDOUT = `---
id: N1
titel: Subnetting rechnen
block: netzwerk
woche: 1
worum: Adresse und Maske ergeben Netz und Broadcast.
rahmenplan: [B2]
lernfeld: LF3
merksatz: Blockgröße finden.
karten:
- frage: Was ist die Blockgröße bei /27?
antwort: 32 Adressen, davon 30 nutzbar.
---
## Das Wesentliche
- Blockgröße bestimmen
## Übersicht
| CIDR | Maske |
|---|---|
| /27 | 255.255.255.224 |
## Prüfungsfrage
**Frage:** Und?
**Antwort:** So.
`;
const GLOSSAR = `- begriff: Subnetzmaske
formen: [Maske]
erklaerung: Trennt Netz und Rechner.
siehe_auch: []
`;
function baueBeispiel(aenderungen: Record<string, string | null> = {}): string {
const wurzel = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-inhalte-'));
aufraeumen.push(wurzel);
const dateien: Record<string, string> = {
'rahmenplan.yaml': RAHMENPLAN,
'glossar.yaml': GLOSSAR,
'wochen/01-netzwerk.yaml': WOCHE,
'handouts/N1-subnetting.md': HANDOUT,
};
for (const [relativ, inhalt] of Object.entries({ ...dateien, ...aenderungen })) {
const ziel = path.join(wurzel, relativ);
if (inhalt === null) {
fs.rmSync(ziel, { force: true });
continue;
}
fs.mkdirSync(path.dirname(ziel), { recursive: true });
fs.writeFileSync(ziel, inhalt, 'utf8');
}
return wurzel;
}
function fehlerVon(wurzel: string) {
try {
ladeInhalte(wurzel);
return null;
} catch (fehler) {
if (fehler instanceof InhaltsFehler) return fehler;
throw fehler;
}
}
describe('Inhalte einlesen', () => {
it('liest ein vollständiges Beispiel ohne Beanstandung', () => {
const alle = ladeInhalte(baueBeispiel());
expect(alle.handouts).toHaveLength(1);
expect(alle.handouts[0]!.id).toBe('N1');
expect(alle.wochen).toHaveLength(1);
expect(alle.karten).toHaveLength(1);
expect(alle.karten[0]!.id).toBe('N1-1');
});
it('zerlegt das Handout in seine festen Abschnitte', () => {
const alle = ladeInhalte(baueBeispiel());
const handout = alle.handouts[0]!;
expect(handout.wesentlich?.titel).toBe('Das Wesentliche');
expect(handout.uebersicht?.html).toContain('<table>');
expect(handout.pruefungsfrage?.html).toContain('Antwort');
});
});
describe('Verweise werden geprüft', () => {
it('meldet eine unbekannte Berufsbildposition mit Datei und Zeile', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('[B2]', '[B9]') }));
expect(fehler).not.toBeNull();
const treffer = fehler!.fehler.find((f) => f.meldung.includes('B9'));
expect(treffer).toBeDefined();
expect(treffer!.datei).toMatch(/\/handouts\/N1-subnetting\.md$/);
expect(treffer!.zeile).toBe(7); // Zeile mit rahmenplan:
expect(treffer!.meldung).toContain('rahmenplan.yaml');
});
it('meldet ein unbekanntes Lernfeld', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('LF3', 'LF9') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('LF9'))).toBe(true);
});
it('meldet ein Handout, dessen Woche es nicht gibt', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('woche: 1', 'woche: 7') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('keine Woche 7'))).toBe(true);
});
it('meldet ein Handout, das in seiner Woche nicht aufgeführt ist', () => {
const fehler = fehlerVon(baueBeispiel({ 'wochen/01-netzwerk.yaml': WOCHE.replace('handouts: [N1]', 'handouts: []') }));
// Sowohl das Handout beschwert sich als auch der Wochenplan über den Theorietag.
expect(fehler!.fehler.some((f) => f.meldung.includes('nicht unter handouts: aufgeführt'))).toBe(true);
});
it('meldet ein Kürzel, das nicht zum Themenblock passt', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('id: N1', 'id: H1') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('Anfangsbuchstaben N'))).toBe(true);
});
it('meldet doppelte Kürzel und nennt die andere Datei', () => {
const fehler = fehlerVon(
baueBeispiel({ 'handouts/N1-zweitfassung.md': HANDOUT.replace('titel: Subnetting rechnen', 'titel: Noch mal') }),
);
expect(fehler!.fehler.some((f) => f.meldung.includes('gibt es schon in'))).toBe(true);
});
it('meldet einen Glossarverweis ins Leere', () => {
const fehler = fehlerVon(baueBeispiel({ 'glossar.yaml': GLOSSAR.replace('siehe_auch: []', 'siehe_auch: [Gateway]') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('Gateway'))).toBe(true);
});
it('meldet eine Woche ohne fünf Tage', () => {
const gekuerzt = WOCHE.split('\n').slice(0, -3).join('\n');
const fehler = fehlerVon(baueBeispiel({ 'wochen/01-netzwerk.yaml': gekuerzt }));
expect(fehler!.fehler.some((f) => f.meldung.includes('genau fünf Tage'))).toBe(true);
});
it('meldet ein Handout ohne Merkkarte', () => {
const ohneKarten = HANDOUT.replace(/karten:\n( - .*\n| .*\n)+/, 'karten: []\n');
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': ohneKarten }));
expect(fehler!.fehler.some((f) => f.meldung.includes('mindestens eine Merkkarte'))).toBe(true);
});
it('meldet ein Handout ohne den Abschnitt „Das Wesentliche"', () => {
const ohne = HANDOUT.replace('## Das Wesentliche', '## Irgendwas anderes');
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': ohne }));
expect(fehler!.fehler.some((f) => f.meldung.includes('Das Wesentliche'))).toBe(true);
});
});
describe('Fehlerdarstellung', () => {
it('sammelt alle Fehler statt beim ersten abzubrechen', () => {
const kaputt = HANDOUT.replace('[B2]', '[B9]').replace('LF3', 'LF9');
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': kaputt }));
expect(fehler!.fehler.length).toBeGreaterThanOrEqual(2);
});
it('nennt in der Meldung Datei und Zeile', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('[B2]', '[B9]') }));
expect(fehler!.message).toMatch(/handouts\/N1-subnetting\.md:7/);
expect(fehler!.message).toContain('npm run inhalte:pruefen');
});
it('meldet kaputtes YAML mit der Zeile, statt einen Stapelabzug zu werfen', () => {
const kaputt = RAHMENPLAN.replace('lernfelder:', 'lernfelder:\n - [ das ist kein gültiges YAML');
const fehler = fehlerVon(baueBeispiel({ 'rahmenplan.yaml': kaputt }));
expect(fehler).not.toBeNull();
expect(fehler!.fehler[0]!.datei).toMatch(/rahmenplan\.yaml$/);
expect(fehler!.fehler[0]!.zeile).toBeGreaterThan(0);
});
});
describe('Die echten Inhalte des Repositorys', () => {
it('lassen sich einlesen und sind in sich stimmig', () => {
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
expect(alle.wochen.length).toBeGreaterThanOrEqual(4);
expect(alle.handouts.length).toBeGreaterThanOrEqual(10);
expect(alle.karten.length).toBeGreaterThanOrEqual(10);
expect(alle.glossar.length).toBeGreaterThanOrEqual(20);
expect(alle.positionen.map((p) => p.id)).toContain('A1');
expect(alle.positionen.map((p) => p.id)).toContain('B2');
expect(alle.positionen.map((p) => p.id)).toContain('C4');
expect(alle.lernfelder).toHaveLength(12);
});
it('vergibt jeder Merkkarte eine eindeutige Kennung', () => {
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
const ids = alle.karten.map((k) => k.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
+695
View File
@@ -0,0 +1,695 @@
import path from 'node:path';
import fs from 'node:fs';
import { Fehlersammler } from './fehler';
import { dateienIn, leseMarkdown, leseYaml, type GeleseneDatei } from './lesen';
import { abschnitt, inAbschnitte, nurText, type Abschnitt } from './markdown';
import {
aufgabeKopfSchema,
checklisteSchema,
glossarSchema,
handoutKopfSchema,
kontrolleSchema,
rahmenplanSchema,
wocheSchema,
type AufgabeKopf,
type Berufsbildposition,
type Checkliste,
type GlossarEintrag,
type HandoutKopf,
type Kontrolle,
type Lernfeld,
type Rahmenplan,
type Themenblock,
type Woche,
} from './schema';
/**
* Liest alles unter content/ ein, prüft es und baut die Verweise auf.
*
* Grundsatz: Fehler werden gesammelt, nicht beim ersten geworfen. Wer zehn
* Tippfehler eingebaut hat, will sie in einem Durchgang sehen.
*/
export type Handout = HandoutKopf & {
datei: string;
rumpf: string;
abschnitte: Abschnitt[];
wesentlich: Abschnitt | undefined;
uebersicht: Abschnitt | undefined;
pruefungsfrage: Abschnitt | undefined;
suchtext: string;
};
export type Aufgabe = AufgabeKopf & {
datei: string;
rumpf: string;
abschnitte: Abschnitt[];
suchtext: string;
};
export type Merkkarte = {
id: string;
handoutId: string;
handoutTitel: string;
block: string;
woche: number;
frage: string;
antwort: string;
};
export type Inhalte = {
rahmenplan: Rahmenplan;
positionen: Berufsbildposition[];
positionNach: Map<string, Berufsbildposition>;
lernfelder: Lernfeld[];
lernfeldNach: Map<string, Lernfeld>;
bloecke: Themenblock[];
blockNach: Map<string, Themenblock>;
wochen: Woche[];
wocheNach: Map<number, Woche>;
handouts: Handout[];
handoutNach: Map<string, Handout>;
handoutsNachBlock: Map<string, Handout[]>;
karten: Merkkarte[];
karteNach: Map<string, Merkkarte>;
aufgaben: Aufgabe[];
aufgabeNach: Map<string, Aufgabe>;
checklisten: Checkliste[];
checklisteNach: Map<string, Checkliste>;
kontrollen: Kontrolle[];
kontrolleNach: Map<string, Kontrolle>;
glossar: GlossarEintrag[];
glossarNach: Map<string, GlossarEintrag>;
/** Alle Dateien, die eingelesen wurden – für das Neuladen im Entwicklungsbetrieb. */
dateien: string[];
gelesenAm: string;
};
function anzeige(wurzel: string, absolut: string): string {
return path.relative(path.dirname(wurzel), absolut).split(path.sep).join('/');
}
export function ladeInhalte(wurzel: string): Inhalte {
const sammler = new Fehlersammler();
const dateien: string[] = [];
if (!fs.existsSync(wurzel)) {
sammler.melde({
datei: wurzel,
meldung: 'Das Inhaltsverzeichnis gibt es nicht. Erwartet wird ein Ordner content/ neben package.json.',
});
sammler.wirfWennNoetig();
}
const zeigeName = (absolut: string) => anzeige(wurzel, absolut);
// --- rahmenplan.yaml ----------------------------------------------------
const rahmenplanDatei = path.join(wurzel, 'rahmenplan.yaml');
dateien.push(rahmenplanDatei);
const rahmenplanGelesen = leseYaml(rahmenplanDatei, zeigeName(rahmenplanDatei), rahmenplanSchema, sammler);
if (!rahmenplanGelesen) {
// Ohne Rahmenplan lässt sich nichts weiter prüfen.
sammler.wirfWennNoetig();
throw new Error('unerreichbar');
}
const rahmenplan = rahmenplanGelesen.werte;
const positionNach = new Map(rahmenplan.berufsbildpositionen.map((p) => [p.id, p]));
const lernfeldNach = new Map(rahmenplan.lernfelder.map((l) => [l.id, l]));
const blockNach = new Map(rahmenplan.bloecke.map((b) => [b.id, b]));
pruefeDoppelte(rahmenplan.berufsbildpositionen.map((p) => p.id), rahmenplanGelesen, 'berufsbildpositionen', 'Berufsbildposition', sammler);
pruefeDoppelte(rahmenplan.lernfelder.map((l) => l.id), rahmenplanGelesen, 'lernfelder', 'Lernfeld', sammler);
pruefeDoppelte(rahmenplan.bloecke.map((b) => b.id), rahmenplanGelesen, 'bloecke', 'Themenblock', sammler);
for (const [index, position] of rahmenplan.berufsbildpositionen.entries()) {
if (!rahmenplan.gruppen[position.gruppe]) {
sammler.melde({
datei: rahmenplanGelesen.datei,
zeile: rahmenplanGelesen.zeileVon(['berufsbildpositionen', index, 'gruppe']),
feld: `berufsbildpositionen[${index}].gruppe`,
meldung: `Die Gruppe „${position.gruppe}" steht nicht unter gruppen:. Bekannt sind: ${Object.keys(rahmenplan.gruppen).join(', ')}.`,
});
}
}
// --- wochen/*.yaml ------------------------------------------------------
const wochen: Woche[] = [];
const wochenQuelle = new Map<number, GeleseneDatei<Woche>>();
for (const absolut of dateienIn(path.join(wurzel, 'wochen'), ['.yaml', '.yml'])) {
dateien.push(absolut);
const gelesen = leseYaml(absolut, zeigeName(absolut), wocheSchema, sammler);
if (!gelesen) continue;
if (wochenQuelle.has(gelesen.werte.nummer)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['nummer']),
feld: 'nummer',
meldung: `Die Wochennummer ${gelesen.werte.nummer} gibt es schon in ${wochenQuelle.get(gelesen.werte.nummer)!.datei}.`,
});
continue;
}
wochenQuelle.set(gelesen.werte.nummer, gelesen);
wochen.push(gelesen.werte);
}
wochen.sort((a, b) => a.nummer - b.nummer);
const wocheNach = new Map(wochen.map((w) => [w.nummer, w]));
// --- handouts/*.md ------------------------------------------------------
const handouts: Handout[] = [];
const handoutQuelle = new Map<string, GeleseneDatei<HandoutKopf>>();
for (const absolut of dateienIn(path.join(wurzel, 'handouts'), '.md')) {
dateien.push(absolut);
const gelesen = leseMarkdown(absolut, zeigeName(absolut), handoutKopfSchema, sammler);
if (!gelesen) continue;
const kopf = gelesen.werte;
if (handoutQuelle.has(kopf.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Das Kürzel ${kopf.id} gibt es schon in ${handoutQuelle.get(kopf.id)!.datei}. Kürzel müssen eindeutig sein.`,
});
continue;
}
handoutQuelle.set(kopf.id, gelesen);
const abschnitte = inAbschnitte(gelesen.rumpf);
const wesentlich = abschnitt(abschnitte, 'Das Wesentliche');
if (!wesentlich) {
sammler.melde({
datei: gelesen.datei,
meldung: 'Es fehlt der Abschnitt „## Das Wesentliche". Jedes Handout hat diesen festen Aufbau.',
});
}
handouts.push({
...kopf,
datei: gelesen.datei,
rumpf: gelesen.rumpf,
abschnitte,
wesentlich,
uebersicht: abschnitt(abschnitte, 'Übersicht'),
pruefungsfrage: abschnitt(abschnitte, 'Prüfungsfrage'),
suchtext: normalisiere(
[kopf.id, kopf.titel, kopf.worum, kopf.merksatz, ...kopf.karten.flatMap((k) => [k.frage, k.antwort]), nurText(gelesen.rumpf)].join(
' \n ',
),
),
});
}
handouts.sort((a, b) => vergleicheKuerzel(a.id, b.id));
const handoutNach = new Map(handouts.map((h) => [h.id, h]));
// --- aufgaben/*.md ------------------------------------------------------
const aufgaben: Aufgabe[] = [];
const aufgabeQuelle = new Map<string, GeleseneDatei<AufgabeKopf>>();
for (const absolut of dateienIn(path.join(wurzel, 'aufgaben'), '.md')) {
dateien.push(absolut);
const gelesen = leseMarkdown(absolut, zeigeName(absolut), aufgabeKopfSchema, sammler);
if (!gelesen) continue;
const kopf = gelesen.werte;
if (aufgabeQuelle.has(kopf.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Die Aufgabenkennung ${kopf.id} gibt es schon in ${aufgabeQuelle.get(kopf.id)!.datei}.`,
});
continue;
}
aufgabeQuelle.set(kopf.id, gelesen);
const abschnitte = inAbschnitte(gelesen.rumpf);
aufgaben.push({
...kopf,
datei: gelesen.datei,
rumpf: gelesen.rumpf,
abschnitte,
suchtext: normalisiere([kopf.id, kopf.titel, kopf.ziel, ...kopf.kriterien, nurText(gelesen.rumpf)].join(' \n ')),
});
}
aufgaben.sort((a, b) => a.id.localeCompare(b.id, 'de-DE', { numeric: true }));
const aufgabeNach = new Map(aufgaben.map((a) => [a.id, a]));
// --- checklisten/*.yaml -------------------------------------------------
const checklisten: Checkliste[] = [];
const checklisteQuelle = new Map<string, GeleseneDatei<Checkliste>>();
for (const absolut of dateienIn(path.join(wurzel, 'checklisten'), ['.yaml', '.yml'])) {
dateien.push(absolut);
const gelesen = leseYaml(absolut, zeigeName(absolut), checklisteSchema, sammler);
if (!gelesen) continue;
const liste = gelesen.werte;
if (checklisteQuelle.has(liste.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Die Checklistenkennung ${liste.id} gibt es schon in ${checklisteQuelle.get(liste.id)!.datei}.`,
});
continue;
}
checklisteQuelle.set(liste.id, gelesen);
const schrittIds = liste.gruppen.flatMap((g) => g.schritte.map((s) => s.id));
pruefeDoppelte(schrittIds, gelesen, 'gruppen', 'Schrittkennung', sammler);
checklisten.push(liste);
}
checklisten.sort((a, b) => a.id.localeCompare(b.id, 'de-DE', { numeric: true }));
const checklisteNach = new Map(checklisten.map((c) => [c.id, c]));
// --- lernzielkontrollen/*.yaml ------------------------------------------
const kontrollen: Kontrolle[] = [];
const kontrolleQuelle = new Map<string, GeleseneDatei<Kontrolle>>();
for (const absolut of dateienIn(path.join(wurzel, 'lernzielkontrollen'), ['.yaml', '.yml'])) {
dateien.push(absolut);
const gelesen = leseYaml(absolut, zeigeName(absolut), kontrolleSchema, sammler);
if (!gelesen) continue;
const kontrolle = gelesen.werte;
if (kontrolleQuelle.has(kontrolle.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Die Kennung ${kontrolle.id} gibt es schon in ${kontrolleQuelle.get(kontrolle.id)!.datei}.`,
});
continue;
}
kontrolleQuelle.set(kontrolle.id, gelesen);
pruefeDoppelte(kontrolle.fragen.map((f) => f.id), gelesen, 'fragen', 'Fragenkennung', sammler);
kontrollen.push(kontrolle);
}
kontrollen.sort((a, b) => a.woche - b.woche);
const kontrolleNach = new Map(kontrollen.map((k) => [k.id, k]));
// --- glossar.yaml -------------------------------------------------------
const glossarDatei = path.join(wurzel, 'glossar.yaml');
dateien.push(glossarDatei);
const glossarGelesen = leseYaml(glossarDatei, zeigeName(glossarDatei), glossarSchema, sammler);
const glossar = glossarGelesen?.werte ?? [];
const glossarNach = new Map(glossar.map((g) => [g.begriff.toLocaleLowerCase('de-DE'), g]));
if (glossarGelesen) {
pruefeDoppelte(glossar.map((g) => g.begriff.toLocaleLowerCase('de-DE')), glossarGelesen, '', 'Glossarbegriff', sammler);
for (const [index, eintrag] of glossar.entries()) {
for (const [j, verweis] of eintrag.siehe_auch.entries()) {
if (!glossarNach.has(verweis.toLocaleLowerCase('de-DE'))) {
sammler.melde({
datei: glossarGelesen.datei,
zeile: glossarGelesen.zeileVon([index, 'siehe_auch', j]),
feld: `${eintrag.begriff}.siehe_auch`,
meldung: `„${verweis}" steht nicht im Glossar.`,
});
}
}
}
}
// --- Verweise prüfen ----------------------------------------------------
pruefeVerweise({
sammler,
rahmenplan,
positionNach,
lernfeldNach,
blockNach,
wochen,
wochenQuelle,
handouts,
handoutQuelle,
handoutNach,
aufgaben,
aufgabeQuelle,
aufgabeNach,
checklisteNach,
kontrolleNach,
kontrollen,
kontrolleQuelle,
wocheNach,
});
sammler.wirfWennNoetig();
// --- Ableitungen --------------------------------------------------------
const handoutsNachBlock = new Map<string, Handout[]>();
for (const block of rahmenplan.bloecke) handoutsNachBlock.set(block.id, []);
for (const handout of handouts) {
handoutsNachBlock.get(handout.block)!.push(handout);
}
const karten: Merkkarte[] = handouts.flatMap((handout) =>
handout.karten.map((karte, index) => ({
id: `${handout.id}-${index + 1}`,
handoutId: handout.id,
handoutTitel: handout.titel,
block: handout.block,
woche: handout.woche,
frage: karte.frage,
antwort: karte.antwort,
})),
);
return {
rahmenplan,
positionen: rahmenplan.berufsbildpositionen,
positionNach,
lernfelder: rahmenplan.lernfelder,
lernfeldNach,
bloecke: rahmenplan.bloecke,
blockNach,
wochen,
wocheNach,
handouts,
handoutNach,
handoutsNachBlock,
karten,
karteNach: new Map(karten.map((k) => [k.id, k])),
aufgaben,
aufgabeNach,
checklisten,
checklisteNach,
kontrollen,
kontrolleNach,
glossar,
glossarNach,
dateien,
gelesenAm: new Date().toISOString(),
};
}
// --- Hilfsmittel ----------------------------------------------------------
function pruefeDoppelte(
werte: string[],
quelle: GeleseneDatei<unknown>,
feld: string,
bezeichnung: string,
sammler: Fehlersammler,
): void {
const gesehen = new Set<string>();
for (const [index, wert] of werte.entries()) {
if (gesehen.has(wert)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(feld ? [feld, index] : [index]),
feld: feld || undefined,
meldung: `${bezeichnung} „${wert}" kommt mehrfach vor.`,
});
}
gesehen.add(wert);
}
}
/** Sortiert N2 vor N10 – reine Zeichenkettensortierung tut das nicht. */
function vergleicheKuerzel(a: string, b: string): number {
const zerlege = (k: string) => [k.slice(0, 1), Number(k.slice(1))] as const;
const [buchstabeA, zahlA] = zerlege(a);
const [buchstabeB, zahlB] = zerlege(b);
if (buchstabeA !== buchstabeB) return buchstabeA.localeCompare(buchstabeB, 'de-DE');
return zahlA - zahlB;
}
export function normalisiere(text: string): string {
return text
.toLocaleLowerCase('de-DE')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/\s+/g, ' ');
}
type Pruefkontext = {
sammler: Fehlersammler;
rahmenplan: Rahmenplan;
positionNach: Map<string, Berufsbildposition>;
lernfeldNach: Map<string, Lernfeld>;
blockNach: Map<string, Themenblock>;
wochen: Woche[];
wocheNach: Map<number, Woche>;
wochenQuelle: Map<number, GeleseneDatei<Woche>>;
handouts: Handout[];
handoutQuelle: Map<string, GeleseneDatei<HandoutKopf>>;
handoutNach: Map<string, Handout>;
aufgaben: Aufgabe[];
aufgabeQuelle: Map<string, GeleseneDatei<AufgabeKopf>>;
aufgabeNach: Map<string, Aufgabe>;
checklisteNach: Map<string, Checkliste>;
kontrollen: Kontrolle[];
kontrolleQuelle: Map<string, GeleseneDatei<Kontrolle>>;
kontrolleNach: Map<string, Kontrolle>;
};
function pruefeVerweise(k: Pruefkontext): void {
const { sammler } = k;
const bekanntePositionen = [...k.positionNach.keys()].join(', ');
const bekannteLernfelder = [...k.lernfeldNach.keys()].join(', ');
const bekannteBloecke = [...k.blockNach.keys()].join(', ');
// --- Handouts ---
for (const handout of k.handouts) {
const quelle = k.handoutQuelle.get(handout.id)!;
const block = k.blockNach.get(handout.block);
if (!block) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['block']),
feld: 'block',
meldung: `Der Themenblock „${handout.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
});
} else if (!handout.id.startsWith(block.kuerzel)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['id']),
feld: 'id',
meldung: `Das Kürzel ${handout.id} passt nicht zum Block „${handout.block}" – dieser verlangt den Anfangsbuchstaben ${block.kuerzel}.`,
});
}
for (const [index, position] of handout.rahmenplan.entries()) {
if (!k.positionNach.has(position)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['rahmenplan', index]),
feld: 'rahmenplan',
meldung: `Die Berufsbildposition „${position}" steht nicht in rahmenplan.yaml. Bekannt: ${bekanntePositionen}.`,
});
}
}
if (!k.lernfeldNach.has(handout.lernfeld)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['lernfeld']),
feld: 'lernfeld',
meldung: `Das Lernfeld „${handout.lernfeld}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteLernfelder}.`,
});
}
const woche = k.wocheNach.get(handout.woche);
if (!woche) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Es gibt keine Woche ${handout.woche}. Jedes Handout muss zu einer Woche gehören.`,
});
} else if (!woche.handouts.includes(handout.id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Das Handout nennt Woche ${handout.woche}, wird dort aber nicht unter handouts: aufgeführt. Trage ${handout.id} in ${k.wochenQuelle.get(handout.woche)!.datei} nach.`,
});
}
}
// --- Aufgaben ---
for (const aufgabe of k.aufgaben) {
const quelle = k.aufgabeQuelle.get(aufgabe.id)!;
if (!k.blockNach.has(aufgabe.block)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['block']),
feld: 'block',
meldung: `Der Themenblock „${aufgabe.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
});
}
for (const [index, position] of aufgabe.rahmenplan.entries()) {
if (!k.positionNach.has(position)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['rahmenplan', index]),
feld: 'rahmenplan',
meldung: `Die Berufsbildposition „${position}" steht nicht in rahmenplan.yaml. Bekannt: ${bekanntePositionen}.`,
});
}
}
if (!k.lernfeldNach.has(aufgabe.lernfeld)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['lernfeld']),
feld: 'lernfeld',
meldung: `Das Lernfeld „${aufgabe.lernfeld}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteLernfelder}.`,
});
}
const woche = k.wocheNach.get(aufgabe.woche);
if (!woche) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Es gibt keine Woche ${aufgabe.woche}.`,
});
} else if (!woche.aufgaben.includes(aufgabe.id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Die Aufgabe nennt Woche ${aufgabe.woche}, wird dort aber nicht unter aufgaben: aufgeführt.`,
});
}
}
// --- Lernzielkontrollen ---
for (const kontrolle of k.kontrollen) {
const quelle = k.kontrolleQuelle.get(kontrolle.id)!;
if (!k.wocheNach.has(kontrolle.woche)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Es gibt keine Woche ${kontrolle.woche}.`,
});
}
}
// --- Wochen ---
for (const woche of k.wochen) {
const quelle = k.wochenQuelle.get(woche.nummer)!;
if (!k.blockNach.has(woche.block)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['block']),
feld: 'block',
meldung: `Der Themenblock „${woche.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
});
}
for (const [index, id] of woche.handouts.entries()) {
if (!k.handoutNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['handouts', index]),
feld: 'handouts',
meldung: `Es gibt kein Handout mit dem Kürzel ${id}.`,
});
}
}
for (const [index, id] of woche.aufgaben.entries()) {
if (!k.aufgabeNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['aufgaben', index]),
feld: 'aufgaben',
meldung: `Es gibt keine Aufgabe mit der Kennung ${id}.`,
});
}
}
for (const [index, id] of woche.checklisten.entries()) {
if (!k.checklisteNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['checklisten', index]),
feld: 'checklisten',
meldung: `Es gibt keine Checkliste mit der Kennung ${id}.`,
});
}
}
if (woche.lernzielkontrolle && !k.kontrolleNach.has(woche.lernzielkontrolle)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['lernzielkontrolle']),
feld: 'lernzielkontrolle',
meldung: `Es gibt keine Lernzielkontrolle mit der Kennung „${woche.lernzielkontrolle}".`,
});
}
for (const [tagIndex, tag] of woche.tage.entries()) {
for (const [index, id] of tag.theorie.entries()) {
if (!k.handoutNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['tage', tagIndex, 'theorie', index]),
feld: `tage[${tagIndex}].theorie`,
meldung: `Es gibt kein Handout mit dem Kürzel ${id}.`,
});
} else if (!woche.handouts.includes(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['tage', tagIndex, 'theorie', index]),
feld: `tage[${tagIndex}].theorie`,
meldung: `${id} steht am ${tag.tag} im Plan, fehlt aber in der Liste handouts: dieser Woche.`,
});
}
}
}
const tage = woche.tage.map((t) => t.tag);
const erwartet = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'];
if (tage.join(',') !== erwartet.join(',')) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['tage']),
feld: 'tage',
meldung: `Die Tage müssen in der Reihenfolge ${erwartet.join(', ')} stehen – gefunden: ${tage.join(', ')}.`,
});
}
}
// --- Lücken in der Wochennummerierung ---
for (let nummer = 1; nummer <= (k.wochen.at(-1)?.nummer ?? 0); nummer += 1) {
if (!k.wocheNach.has(nummer)) {
sammler.melde({
datei: 'content/wochen/',
meldung: `Woche ${nummer} fehlt, obwohl es spätere Wochen gibt. Die Nummerierung muss lückenlos sein.`,
});
}
}
}
+200
View File
@@ -0,0 +1,200 @@
import fs from 'node:fs';
import path from 'node:path';
import { LineCounter, parseDocument, type Document } from 'yaml';
import type { z } from 'zod';
import type { Einzelfehler, Fehlersammler } from './fehler';
/**
* Liest YAML- und Markdown-Dateien so ein, dass jeder Prüffehler auf eine Zeile
* in der Ursprungsdatei zeigt. Dafür wird nicht `YAML.parse` benutzt, sondern
* das Dokumentmodell – nur das kennt die Positionen der einzelnen Knoten.
*/
export type GeleseneDatei<T> = {
datei: string;
werte: T;
/** Findet die Zeile zu einem Pfad wie ['karten', 0, 'frage']. */
zeileVon: (pfad: (string | number)[]) => number | undefined;
};
type Leseergebnis = {
dokument: Document.Parsed;
zaehler: LineCounter;
/** Anzahl der Zeilen, die vor dem YAML stehen (bei Markdown der `---`-Anfang). */
versatz: number;
};
function leseYamlDokument(text: string, versatz: number): Leseergebnis {
const zaehler = new LineCounter();
const dokument = parseDocument(text, { lineCounter: zaehler, keepSourceTokens: false });
return { dokument, zaehler, versatz };
}
function zeileFuerPfad(ergebnis: Leseergebnis, pfad: (string | number)[]): number | undefined {
const { dokument, zaehler, versatz } = ergebnis;
// Vom vollständigen Pfad rückwärts kürzen, bis ein Knoten gefunden wird.
// So landet man bei einem Tippfehler im Feldnamen wenigstens beim Elternknoten.
for (let laenge = pfad.length; laenge >= 0; laenge -= 1) {
const teil = pfad.slice(0, laenge);
let knoten: unknown;
try {
knoten = teil.length === 0 ? dokument.contents : dokument.getIn(teil, true);
} catch {
continue;
}
const bereich = (knoten as { range?: [number, number, number] } | undefined)?.range;
if (bereich) {
const stelle = zaehler.linePos(bereich[0]);
return stelle.line + versatz;
}
}
return undefined;
}
/** Liest eine reine YAML-Datei und prüft sie gegen ein Zod-Schema. */
export function leseYaml<S extends z.ZodTypeAny>(
absolut: string,
anzeigepfad: string,
schema: S,
sammler: Fehlersammler,
): GeleseneDatei<z.output<S>> | null {
let text: string;
try {
text = fs.readFileSync(absolut, 'utf8');
} catch (fehler) {
sammler.melde({ datei: anzeigepfad, meldung: `Datei nicht lesbar: ${(fehler as Error).message}` });
return null;
}
let ergebnis: Leseergebnis;
try {
ergebnis = leseYamlDokument(text, 0);
} catch (fehler) {
sammler.melde({ datei: anzeigepfad, meldung: `YAML nicht lesbar: ${(fehler as Error).message}` });
return null;
}
for (const fehler of ergebnis.dokument.errors) {
const stelle = ergebnis.zaehler.linePos(fehler.pos[0]);
sammler.melde({
datei: anzeigepfad,
zeile: stelle.line,
spalte: stelle.col,
meldung: fehler.message,
});
}
if (ergebnis.dokument.errors.length > 0) return null;
return pruefe(ergebnis.dokument.toJS(), ergebnis, anzeigepfad, schema, sammler);
}
/** Liest eine Markdown-Datei mit YAML-Kopf. Der Rumpf landet als `rumpf` im Ergebnis. */
export function leseMarkdown<S extends z.ZodTypeAny>(
absolut: string,
anzeigepfad: string,
schema: S,
sammler: Fehlersammler,
): (GeleseneDatei<z.output<S>> & { rumpf: string }) | null {
let text: string;
try {
text = fs.readFileSync(absolut, 'utf8');
} catch (fehler) {
sammler.melde({ datei: anzeigepfad, meldung: `Datei nicht lesbar: ${(fehler as Error).message}` });
return null;
}
if (!text.startsWith('---')) {
sammler.melde({
datei: anzeigepfad,
zeile: 1,
meldung: 'Die Datei beginnt nicht mit einem YAML-Kopf. Erwartet wird eine Zeile mit genau ---.',
});
return null;
}
const getrennt = trenneKopfUndRumpf(text);
if (!getrennt) {
sammler.melde({
datei: anzeigepfad,
zeile: 1,
meldung: 'Der YAML-Kopf wird nicht abgeschlossen. Erwartet wird eine weitere Zeile mit genau ---.',
});
return null;
}
// Der YAML-Kopf beginnt in Zeile 2 – die erste Zeile ist das ---.
const ergebnis = leseYamlDokument(getrennt.kopf, 1);
for (const fehler of ergebnis.dokument.errors) {
const stelle = ergebnis.zaehler.linePos(fehler.pos[0]);
sammler.melde({
datei: anzeigepfad,
zeile: stelle.line + 1,
spalte: stelle.col,
meldung: fehler.message,
});
}
if (ergebnis.dokument.errors.length > 0) return null;
const geprueft = pruefe(ergebnis.dokument.toJS(), ergebnis, anzeigepfad, schema, sammler);
if (!geprueft) return null;
return { ...geprueft, rumpf: getrennt.rumpf.trim() };
}
/**
* Trennt den YAML-Kopf vom Markdown-Rumpf. Bewusst von Hand statt über eine
* Bibliothek: Nur so ist sicher, dass der Zeilenversatz für die Fehlermeldungen
* stimmt – und es ist eine Abhängigkeit weniger.
*/
function trenneKopfUndRumpf(text: string): { kopf: string; rumpf: string } | null {
const treffer = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
if (!treffer) return null;
return { kopf: treffer[1] ?? '', rumpf: text.slice(treffer[0].length) };
}
function pruefe<S extends z.ZodTypeAny>(
rohwerte: unknown,
ergebnis: Leseergebnis,
anzeigepfad: string,
schema: S,
sammler: Fehlersammler,
): GeleseneDatei<z.output<S>> | null {
const ausgang = schema.safeParse(rohwerte ?? {});
if (!ausgang.success) {
for (const problem of ausgang.error.issues) {
const zeile = zeileFuerPfad(ergebnis, problem.path as (string | number)[]);
sammler.melde({
datei: anzeigepfad,
zeile,
feld: problem.path.length > 0 ? problem.path.join('.') : undefined,
meldung: problem.message,
});
}
return null;
}
return {
datei: anzeigepfad,
werte: ausgang.data,
zeileVon: (pfad) => zeileFuerPfad(ergebnis, pfad),
};
}
/** Alle Dateien eines Verzeichnisses mit passender Endung, nach Namen sortiert. */
export function dateienIn(verzeichnis: string, endung: string | string[]): string[] {
if (!fs.existsSync(verzeichnis)) return [];
const endungen = Array.isArray(endung) ? endung : [endung];
return fs
.readdirSync(verzeichnis)
.filter((d) => endungen.some((e) => d.endsWith(e)))
.sort()
.map((d) => path.join(verzeichnis, d));
}
/** Erzeugt aus einer Fundstelle einen Einzelfehler – Kurzform für die Prüfschritte. */
export function fehlerAn(datei: string, zeile: number | undefined, feld: string, meldung: string): Einzelfehler {
return { datei, zeile, feld, meldung };
}
+74
View File
@@ -0,0 +1,74 @@
import MarkdownIt from 'markdown-it';
/**
* Markdown wird beim Einlesen einmal nach HTML übersetzt und liegt dann fertig
* im Speicher. Die Inhalte stammen aus dem Repository, sind also vertrauenswürdig
* – trotzdem bleibt rohes HTML abgeschaltet, damit sich niemand daran gewöhnt.
*/
const uebersetzer = new MarkdownIt({
html: false,
linkify: false,
typographer: false,
breaks: false,
});
export function nachHtml(markdown: string): string {
return uebersetzer.render(markdown).trim();
}
export function nachHtmlInline(markdown: string): string {
return uebersetzer.renderInline(markdown).trim();
}
/** Entfernt Auszeichnung, damit der Text durchsucht werden kann. */
export function nurText(markdown: string): string {
return markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/^[#>\-*+|]+\s?/gm, ' ')
.replace(/[*_~]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export type Abschnitt = { titel: string; markdown: string; html: string };
/**
* Zerlegt den Rumpf eines Handouts an den Überschriften der zweiten Ebene.
* Der feste Aufbau eines Handouts – Das Wesentliche, Übersicht, Prüfungsfrage –
* wird dadurch für die Darstellung einzeln greifbar.
*/
export function inAbschnitte(rumpf: string): Abschnitt[] {
const zeilen = rumpf.split('\n');
const abschnitte: Abschnitt[] = [];
let titel = '';
let puffer: string[] = [];
const ablegen = () => {
const markdown = puffer.join('\n').trim();
if (titel || markdown) {
abschnitte.push({ titel, markdown, html: nachHtml(markdown) });
}
puffer = [];
};
for (const zeile of zeilen) {
const treffer = /^##\s+(.+?)\s*$/.exec(zeile);
if (treffer) {
ablegen();
titel = treffer[1]!;
} else {
puffer.push(zeile);
}
}
ablegen();
return abschnitte.filter((a) => a.titel !== '' || a.markdown !== '');
}
/** Sucht einen Abschnitt anhand seiner Überschrift, unabhängig von Groß- und Kleinschreibung. */
export function abschnitt(abschnitte: Abschnitt[], titel: string): Abschnitt | undefined {
const gesucht = titel.toLocaleLowerCase('de-DE');
return abschnitte.find((a) => a.titel.toLocaleLowerCase('de-DE') === gesucht);
}
+186
View File
@@ -0,0 +1,186 @@
import { z } from 'zod';
/**
* Die Gestalt der Inhaltsdateien. Fehlermeldungen sind bewusst so formuliert,
* dass sie in der Startmeldung direkt lesbar sind – der Leser sitzt in VS Code
* und will wissen, was er tippen soll.
*/
const nichtLeer = (was: string) => z.string({ required_error: `${was} fehlt.` }).trim().min(1, `${was} darf nicht leer sein.`);
/** Kürzel eines Handouts: ein Buchstabe für den Block, dann eine Zahl. */
export const handoutKuerzel = z
.string()
.regex(/^[A-Z]\d{1,2}$/, 'Kürzel muss aus einem Großbuchstaben und ein bis zwei Ziffern bestehen, z. B. N3.');
export const rahmenplanKuerzel = z
.string()
.regex(/^[ABC]\d{1,2}$/, 'Berufsbildposition muss wie A1, B2 oder C4 aussehen.');
export const lernfeldKuerzel = z.string().regex(/^LF\d{1,2}$/, 'Lernfeld muss wie LF3 aussehen.');
// --- rahmenplan.yaml ------------------------------------------------------
export const berufsbildpositionSchema = z.object({
id: rahmenplanKuerzel,
gruppe: nichtLeer('Die Gruppe'),
titel: nichtLeer('Der Titel'),
kurz: nichtLeer('Die Kurzform'),
verordnung: nichtLeer('Die Fundstelle in der Verordnung'),
});
export const lernfeldSchema = z.object({
id: lernfeldKuerzel,
titel: nichtLeer('Der Titel'),
jahr: z.number().int().min(1).max(3),
stunden: z.number().int().positive(),
});
export const blockSchema = z.object({
id: nichtLeer('Die Kennung des Blocks'),
kuerzel: z.string().regex(/^[A-Z]$/, 'Das Blockkürzel ist genau ein Großbuchstabe.'),
titel: nichtLeer('Der Titel'),
farbe: z.string().optional(),
});
export const rahmenplanSchema = z.object({
quellen: z.object({
betrieb: z.object({ titel: z.string(), kurz: z.string(), vom: z.coerce.string(), fundstelle: z.string() }),
schule: z.object({ titel: z.string(), kurz: z.string(), vom: z.coerce.string() }),
}),
berufsbildpositionen: z.array(berufsbildpositionSchema).min(1, 'Es muss mindestens eine Berufsbildposition geben.'),
gruppen: z.record(z.string()),
lernfelder: z.array(lernfeldSchema).min(1, 'Es muss mindestens ein Lernfeld geben.'),
bloecke: z.array(blockSchema).min(1, 'Es muss mindestens einen Themenblock geben.'),
});
// --- handouts/*.md --------------------------------------------------------
export const karteSchema = z.object({
frage: nichtLeer('Die Frage der Merkkarte'),
antwort: nichtLeer('Die Antwort der Merkkarte'),
});
export const handoutKopfSchema = z.object({
id: handoutKuerzel,
titel: nichtLeer('Der Titel'),
block: nichtLeer('Der Themenblock'),
woche: z.number().int().positive('Die Wochennummer muss eine positive ganze Zahl sein.'),
worum: nichtLeer('Das Feld „worum" – ein Satz, worum es geht'),
rahmenplan: z.array(rahmenplanKuerzel).min(1, 'Mindestens eine Berufsbildposition angeben.'),
lernfeld: lernfeldKuerzel,
merksatz: nichtLeer('Der Merksatz'),
karten: z.array(karteSchema).min(1, 'Zu jedem Handout gehört mindestens eine Merkkarte.'),
});
// --- aufgaben/*.md --------------------------------------------------------
export const aufgabeKopfSchema = z.object({
id: z.coerce.string().regex(/^\d+\.\d+$/, 'Die Aufgabenkennung sieht aus wie 2.2 – Woche, Punkt, laufende Nummer.'),
titel: nichtLeer('Der Titel'),
woche: z.number().int().positive(),
block: nichtLeer('Der Themenblock'),
ziel: nichtLeer('Das Ziel der Aufgabe'),
zeitrahmen: nichtLeer('Der Zeitrahmen'),
rahmenplan: z.array(rahmenplanKuerzel).min(1, 'Mindestens eine Berufsbildposition angeben.'),
lernfeld: lernfeldKuerzel,
material: z.array(z.string().min(1)).default([]),
kriterien: z.array(z.string().min(1)).min(1, 'Ohne Bewertungskriterien lässt sich die Aufgabe nicht abnehmen.'),
});
// --- wochen/*.yaml --------------------------------------------------------
export const wochentagSchema = z.object({
tag: z.enum(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'], {
errorMap: () => ({ message: 'Der Tag muss Montag, Dienstag, Mittwoch, Donnerstag oder Freitag heißen.' }),
}),
theorie: z.array(handoutKuerzel).default([]),
praxis: z.string().default(''),
});
export const wocheSchema = z.object({
nummer: z.number().int().positive('Die Wochennummer muss eine positive ganze Zahl sein.'),
titel: nichtLeer('Der Titel der Woche'),
block: nichtLeer('Der Themenblock'),
leitfrage: nichtLeer('Die Leitfrage'),
ziel: nichtLeer('Das Wochenziel'),
handouts: z.array(handoutKuerzel).default([]),
aufgaben: z.array(z.coerce.string()).default([]),
checklisten: z.array(z.string()).default([]),
lernzielkontrolle: z.string().nullish().default(null),
tage: z.array(wochentagSchema).length(5, 'Eine Woche hat genau fünf Tage – Montag bis Freitag.'),
});
// --- checklisten/*.yaml ---------------------------------------------------
export const schrittSchema = z.object({
id: nichtLeer('Die Schrittkennung'),
text: nichtLeer('Der Text des Schrittes'),
});
export const gruppenKennungen = ['vorbereiten', 'durchfuehren', 'pruefen', 'abschliessen'] as const;
export const checklistenGruppeSchema = z.object({
id: z.enum(gruppenKennungen, {
errorMap: () => ({ message: 'Gruppe muss vorbereiten, durchfuehren, pruefen oder abschliessen heißen.' }),
}),
titel: nichtLeer('Der Titel der Gruppe'),
schritte: z.array(schrittSchema).min(1, 'Eine Gruppe ohne Schritte ergibt keinen Sinn.'),
});
export const checklisteSchema = z.object({
id: nichtLeer('Die Kennung der Checkliste'),
titel: nichtLeer('Der Titel'),
block: nichtLeer('Der Themenblock'),
worum: nichtLeer('Ein Satz, worum es geht'),
rahmenplan: z.array(rahmenplanKuerzel).default([]),
lernfeld: lernfeldKuerzel.optional(),
mit_ticket: z.boolean().default(false),
gruppen: z.array(checklistenGruppeSchema).min(1, 'Mindestens eine Gruppe angeben.'),
});
// --- lernzielkontrollen/*.yaml -------------------------------------------
export const kontrollfrageSchema = z.object({
id: nichtLeer('Die Fragenkennung'),
frage: nichtLeer('Der Fragetext'),
punkte: z.number().int().positive('Punkte je Frage müssen eine positive ganze Zahl sein.'),
musterloesung: nichtLeer('Die Musterlösung'),
});
export const kontrolleSchema = z.object({
id: nichtLeer('Die Kennung der Lernzielkontrolle'),
woche: z.number().int().positive(),
titel: nichtLeer('Der Titel'),
dauer_minuten: z.number().int().positive().optional(),
hinweis: z.string().default(''),
fragen: z.array(kontrollfrageSchema).min(1, 'Eine Lernzielkontrolle ohne Fragen ergibt keinen Sinn.'),
});
// --- glossar.yaml ---------------------------------------------------------
export const glossarEintragSchema = z.object({
begriff: nichtLeer('Der Begriff'),
steht_fuer: z.string().optional(),
formen: z.array(z.string().min(1)).default([]),
erklaerung: nichtLeer('Die Erklärung'),
siehe_auch: z.array(z.string()).default([]),
});
export const glossarSchema = z.array(glossarEintragSchema).min(1, 'Das Glossar ist leer.');
// --- Typen ----------------------------------------------------------------
export type Berufsbildposition = z.infer<typeof berufsbildpositionSchema>;
export type Lernfeld = z.infer<typeof lernfeldSchema>;
export type Themenblock = z.infer<typeof blockSchema>;
export type Rahmenplan = z.infer<typeof rahmenplanSchema>;
export type Karte = z.infer<typeof karteSchema>;
export type HandoutKopf = z.infer<typeof handoutKopfSchema>;
export type AufgabeKopf = z.infer<typeof aufgabeKopfSchema>;
export type Woche = z.infer<typeof wocheSchema>;
export type Checkliste = z.infer<typeof checklisteSchema>;
export type ChecklistenGruppe = z.infer<typeof checklistenGruppeSchema>;
export type Kontrolle = z.infer<typeof kontrolleSchema>;
export type Kontrollfrage = z.infer<typeof kontrollfrageSchema>;
export type GlossarEintrag = z.infer<typeof glossarEintragSchema>;
+141
View File
@@ -0,0 +1,141 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { ladeInhalte } from './laden';
import { sucheHandouts, suchbegriffe } from './suche';
import { musterFuer, verlinkeGlossar, glossarAnker } from './glossarverweise';
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
describe('Suchbegriffe zerlegen', () => {
it('trennt an Leerzeichen und ignoriert einzelne Buchstaben', () => {
expect(suchbegriffe('subnetting rechnen a')).toEqual(['subnetting', 'rechnen']);
});
it('hält eine Wortgruppe in Anführungszeichen zusammen', () => {
expect(suchbegriffe('"das wesentliche"')).toEqual(['das wesentliche']);
});
it('macht Umlaute und Großschreibung unerheblich', () => {
expect(suchbegriffe('BLOCKGRÖSSE')).toEqual(['blockgroesse']);
});
});
describe('Handouts durchsuchen', () => {
it('findet über den Titel', () => {
const treffer = sucheHandouts(alle, 'subnetting');
expect(treffer[0]!.handout.id).toBe('N3');
});
it('findet über das Kürzel', () => {
const treffer = sucheHandouts(alle, 'N3');
expect(treffer.map((t) => t.handout.id)).toContain('N3');
});
it('findet auch Text, der nur im Rumpf steht', () => {
const treffer = sucheHandouts(alle, 'Kondensatoren');
expect(treffer.map((t) => t.handout.id)).toContain('H1');
});
it('findet über den Text einer Merkkarte', () => {
const treffer = sucheHandouts(alle, 'Wearout');
expect(treffer.map((t) => t.handout.id)).toContain('H2');
});
it('verlangt, dass alle Begriffe vorkommen', () => {
const einer = sucheHandouts(alle, 'netz');
const beide = sucheHandouts(alle, 'netz kondensatoren');
expect(beide.length).toBeLessThan(einer.length);
expect(beide.every((t) => einer.some((e) => e.handout.id === t.handout.id))).toBe(true);
});
it('gewichtet den Titel höher als den Fließtext', () => {
const treffer = sucheHandouts(alle, 'subnetting');
const punkteN3 = treffer.find((t) => t.handout.id === 'N3')!.punkte;
const andere = treffer.filter((t) => t.handout.id !== 'N3');
for (const t of andere) expect(punkteN3).toBeGreaterThan(t.punkte);
});
it('gibt bei leerer Eingabe nichts zurück statt alles', () => {
expect(sucheHandouts(alle, '')).toEqual([]);
expect(sucheHandouts(alle, ' ')).toEqual([]);
});
it('ist unempfindlich gegen Umlaute und Großschreibung', () => {
const mit = sucheHandouts(alle, 'Blockgröße');
const ohne = sucheHandouts(alle, 'blockgroesse');
expect(mit.map((t) => t.handout.id)).toEqual(ohne.map((t) => t.handout.id));
expect(mit.length).toBeGreaterThan(0);
});
it('liefert zu jedem Treffer einen Ausschnitt', () => {
for (const treffer of sucheHandouts(alle, 'netz')) {
expect(treffer.ausschnitt.length).toBeGreaterThan(10);
}
});
});
describe('Glossarbegriffe verlinken', () => {
const glossar = alle.glossar;
it('verlinkt einen bekannten Begriff', () => {
const html = verlinkeGlossar('<p>Die Subnetzmaske entscheidet.</p>', glossar);
expect(html).toContain('href="/glossar#subnetzmaske"');
expect(html).toContain('>Subnetzmaske</a>');
});
it('verlinkt jeden Begriff höchstens einmal', () => {
const html = verlinkeGlossar('<p>DNS hier, DNS da, DNS überall.</p>', glossar);
expect(html.match(/glossarbegriff/g)).toHaveLength(1);
});
it('lässt Code, Überschriften und bestehende Verweise in Ruhe', () => {
expect(verlinkeGlossar('<code>DNS</code>', glossar)).toBe('<code>DNS</code>');
expect(verlinkeGlossar('<h2>DNS</h2>', glossar)).toBe('<h2>DNS</h2>');
expect(verlinkeGlossar('<a href="/x">DNS</a>', glossar)).toBe('<a href="/x">DNS</a>');
expect(verlinkeGlossar('<pre>DNS</pre>', glossar)).toBe('<pre>DNS</pre>');
});
it('trifft nur ganze Wörter', () => {
// „Ports" enthält „Port", ist aber ein anderes Wort mit Endung – hier soll
// nichts passieren, weil die Form nicht angegeben ist.
const html = verlinkeGlossar('<p>Transportweg und Portweite.</p>', glossar);
expect(html).not.toContain('glossarbegriff');
});
it('erkennt zusätzlich angegebene Formen', () => {
const html = verlinkeGlossar('<p>Die Maske sagt es.</p>', glossar);
expect(html).toContain('href="/glossar#subnetzmaske"');
expect(html).toContain('>Maske</a>');
});
it('bevorzugt die längere Form – MAC-Adresse wird nicht zu Adresse', () => {
const html = verlinkeGlossar('<p>Die MAC-Adresse steht fest.</p>', glossar);
expect(html).toContain('>MAC-Adresse</a>');
});
it('maskiert Anführungszeichen in der Erklärung', () => {
const html = verlinkeGlossar('<p>Der Begriff Test.</p>', [
{ begriff: 'Test', formen: [], erklaerung: 'Ein "Anführungszeichen" und <spitze> Klammern.', siehe_auch: [] },
]);
expect(html).toContain('&quot;Anf');
expect(html).toContain('&lt;spitze&gt;');
expect(html).not.toContain('title="Ein "Anführungszeichen"');
});
it('lässt HTML unverändert, wenn das Glossar leer ist', () => {
expect(verlinkeGlossar('<p>DNS</p>', [])).toBe('<p>DNS</p>');
});
it('bildet Anker ohne Umlaute und Sonderzeichen', () => {
expect(glossarAnker('Blockgröße')).toBe('blockgroesse');
expect(glossarAnker('MAC-Adresse')).toBe('mac-adresse');
expect(glossarAnker('Prinzip der geringsten Rechte')).toBe('prinzip-der-geringsten-rechte');
});
it('ordnet die Suchmuster von lang nach kurz', () => {
const muster = musterFuer(glossar);
for (let i = 1; i < muster.length; i += 1) {
expect(muster[i - 1]!.muster.source.length).toBeGreaterThanOrEqual(muster[i]!.muster.source.length);
}
});
});
+82
View File
@@ -0,0 +1,82 @@
import { normalisiere, type Handout, type Inhalte } from './laden';
/**
* Volltextsuche über die Handouts.
*
* Bewusst kein Suchindex und keine Bibliothek: Bei 52 Handouts ist ein
* Durchlauf über alle Texte im Mikrosekundenbereich, und ein Suchindex wäre
* eine zweite Wahrheit, die man pflegen müsste.
*/
export type Fundstueck = {
handout: Handout;
punkte: number;
ausschnitt: string;
};
/** Zerlegt die Eingabe in Wörter; Anführungszeichen halten eine Wortgruppe zusammen. */
export function suchbegriffe(eingabe: string): string[] {
const treffer = eingabe.match(/"[^"]+"|\S+/g) ?? [];
return treffer.map((t) => normalisiere(t.replace(/^"|"$/g, ''))).filter((t) => t.length > 1);
}
export function sucheHandouts(alle: Inhalte, eingabe: string, grenze = 30): Fundstueck[] {
const begriffe = suchbegriffe(eingabe);
if (begriffe.length === 0) return [];
const gefunden: Fundstueck[] = [];
for (const handout of alle.handouts) {
const titel = normalisiere(`${handout.id} ${handout.titel}`);
const worum = normalisiere(handout.worum);
const merksatz = normalisiere(handout.merksatz);
let punkte = 0;
let alleEnthalten = true;
for (const begriff of begriffe) {
let treffer = 0;
if (titel.includes(begriff)) treffer += 10;
if (worum.includes(begriff)) treffer += 5;
if (merksatz.includes(begriff)) treffer += 4;
if (handout.suchtext.includes(begriff)) treffer += 1;
if (treffer === 0) {
alleEnthalten = false;
break;
}
punkte += treffer;
}
if (!alleEnthalten) continue;
gefunden.push({ handout, punkte, ausschnitt: ausschnittUm(handout, begriffe[0]!) });
}
return gefunden
.sort((a, b) => b.punkte - a.punkte || a.handout.id.localeCompare(b.handout.id, 'de-DE'))
.slice(0, grenze);
}
/**
* Ein Stück Text rund um den ersten Treffer, damit man in der Trefferliste
* sieht, warum etwas gefunden wurde.
*/
function ausschnittUm(handout: Handout, begriff: string, laenge = 160): string {
const stelle = handout.suchtext.indexOf(begriff);
if (stelle === -1) return handout.worum;
// Der normalisierte Text hat dieselbe Länge wie das Original, weil nur
// kleingeschrieben und Leerraum vereinheitlicht wird – bis auf Umlaute.
// Für einen Ausschnitt reicht das allemal.
const roh = `${handout.id} ${handout.titel} ${handout.worum} ${handout.merksatz} ${handout.rumpf}`
.replace(/\s+/g, ' ')
.trim();
const mitte = Math.min(stelle, Math.max(0, roh.length - 1));
const von = Math.max(0, mitte - Math.floor(laenge / 3));
const bis = Math.min(roh.length, von + laenge);
const teil = roh.slice(von, bis).trim();
return `${von > 0 ? '… ' : ''}${teil}${bis < roh.length ? ' …' : ''}`;
}
+43
View File
@@ -0,0 +1,43 @@
import { and, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { jetzt } from '../zeit';
/** Fortschritt beim Lesen der Handouts. */
export function gelesenSet(benutzerId: string): Set<string> {
const zeilen = datenbank()
.select({ handoutId: schema.handoutGelesen.handoutId })
.from(schema.handoutGelesen)
.where(eq(schema.handoutGelesen.benutzerId, benutzerId))
.all();
return new Set(zeilen.map((z) => z.handoutId));
}
export function gelesenAm(benutzerId: string, handoutId: string): string | null {
const zeile = datenbank()
.select({ gelesenAm: schema.handoutGelesen.gelesenAm })
.from(schema.handoutGelesen)
.where(and(eq(schema.handoutGelesen.benutzerId, benutzerId), eq(schema.handoutGelesen.handoutId, handoutId)))
.get();
return zeile?.gelesenAm ?? null;
}
export function alsGelesenMerken(benutzerId: string, handoutId: string): void {
datenbank()
.insert(schema.handoutGelesen)
.values({ benutzerId, handoutId, gelesenAm: jetzt() })
.onConflictDoNothing()
.run();
}
export function alsUngelesenMerken(benutzerId: string, handoutId: string): void {
datenbank()
.delete(schema.handoutGelesen)
.where(and(eq(schema.handoutGelesen.benutzerId, benutzerId), eq(schema.handoutGelesen.handoutId, handoutId)))
.run();
}
/** Anzahl gelesener Handouts – für die Fortschrittsanzeige. */
export function anzahlGelesen(benutzerId: string): number {
return gelesenSet(benutzerId).size;
}