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:
co-authored by
Claude Opus 5
parent
e856349cd4
commit
1af5613e9b
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user