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 { const wurzel = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-inhalte-')); aufraeumen.push(wurzel); const dateien: Record = { '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(''); 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); }); });