/** * Das Handbuch als Datenbestand. * * Diese Tests sehen sich keine Oberfläche an. Sie halten den Inhalt selbst * zusammen: Ein Handbuch, das ausläuft, sagt mit der Autorität eines * Handbuchs etwas Falsches – schlimmer als gar keines. Deshalb prüfen sie * nicht nur, dass Text dasteht, sondern dass er aus denselben Konstanten * kommt wie die Anwendung. */ import { describe, expect, it } from 'vitest'; import { HILFE_EINLEITUNG, HILFE_KAPITEL, HILFE_TITEL, HILFE_ZUR_ANSICHT, TASTATURBEDIENUNG, TASTENKUERZEL, type Hilfeblock, type Tastenzeile, } from '../src/shared/hilfe'; import { ANZEIGEGROESSEN } from '../src/shared/ansicht'; import { BEWERTUNG_BEZEICHNUNG } from '../src/shared/lernstand'; import { STUFE_WORT } from '../src/shared/reife'; import { KONTAKT } from '../src/shared/kontakt'; import { SITZUNGSUMFANG } from '../src/shared/lernplan'; import { PRUEFUNGSPROFILE } from '../src/shared/pruefung'; /** Alle Textstellen eines Blocks, gleich welcher Art. */ function texte(block: Hilfeblock): readonly string[] { switch (block.art) { case 'absatz': return [block.text]; case 'liste': case 'schritte': return block.punkte; case 'tasten': return block.zeilen.map((zeile) => zeile.wirkung); } } /** Der gesamte Fließtext des Handbuchs – für Suchen nach Zahlen. */ const GESAMTTEXT = HILFE_KAPITEL.flatMap((kapitel) => kapitel.bloecke.flatMap(texte)).join('\n'); const ALLE_TASTENZEILEN: readonly Tastenzeile[] = [...TASTENKUERZEL, ...TASTATURBEDIENUNG]; describe('Handbuch – Aufbau', () => { it('hat einen Titel und eine Einleitung', () => { expect(HILFE_TITEL.trim().length).toBeGreaterThan(0); expect(HILFE_EINLEITUNG.trim().length).toBeGreaterThan(0); }); it('vergibt jede Kapitelkennung genau einmal', () => { /* Die Kennungen sind Sprungziele des Inhaltsverzeichnisses. Zwei gleiche hießen: Ein Eintrag führt woandershin, als er verspricht. */ const kennungen = HILFE_KAPITEL.map((kapitel) => kapitel.id); expect(new Set(kennungen).size).toBe(kennungen.length); }); it('gibt jedem Kapitel Kennung, Titel, Kurztext und Inhalt', () => { for (const kapitel of HILFE_KAPITEL) { expect(kapitel.id, `Kapitel ohne Kennung: ${kapitel.titel}`).toMatch(/^[a-z][a-z-]*$/u); expect(kapitel.titel.trim().length, `${kapitel.id}: kein Titel`).toBeGreaterThan(0); expect(kapitel.kurz.trim().length, `${kapitel.id}: kein Kurztext`).toBeGreaterThan(0); expect(kapitel.bloecke.length, `${kapitel.id}: kein Inhalt`).toBeGreaterThan(0); } }); it('lässt nirgends eine leere Textstelle stehen', () => { for (const kapitel of HILFE_KAPITEL) { for (const block of kapitel.bloecke) { for (const text of texte(block)) { expect(text.trim().length, `${kapitel.id}: leere Textstelle`).toBeGreaterThan(0); } } } }); it('schreibt kein Markup in den Text', () => { /* Die Blöcke werden als reiner Text gerendert, nie über `dangerouslySetInnerHTML`. Stünde hier eine spitze Klammer, erschiene sie wörtlich auf dem Bildschirm – der Fehler fiele erst dort auf. */ expect(GESAMTTEXT).not.toMatch(/<[a-z/]/iu); }); /* Dieselbe Falle, andere Zeichen — und sie hatte zugeschnappt. Bis Fassung 0.24.2 standen fünf Markdown-Auszeichnungen im sichtbaren Handbuchtext: dreimal im Kapitel „Wiedervorlage und Lernplan“ (Lernbericht, Fehlerprotokoll, Fragenliste) und zweimal in „Profile und Ihre Daten“ (Ersetzen, Ein Profil dazunehmen). `Hilfedialog.tsx` rendert `
{block.text}
`; auf dem Bildschirm stand also wörtlich „**Lernbericht**“. Die Prüfung auf spitze Klammern darüber griff nicht — Markdown hat keine. Wer eine Stelle hervorheben will, teilt den Satz oder setzt deutsche Anführungszeichen, so wie das Handbuch es sonst überall tut. */ it('schreibt auch kein Markdown in den Text', () => { const auszeichnungen: readonly (readonly [RegExp, string])[] = [ [/\*\*[^*]+\*\*/u, 'fette Auszeichnung mit **'], [/^\s*#{1,6}\s/mu, 'Überschrift mit Doppelkreuz'], [/\[[^\]]+\]\([^)]+\)/u, 'Verweis in eckigen Klammern'], [/`[^`]+`/u, 'Schreibmaschinenschrift mit Rückstrich'], ]; const befunde = auszeichnungen .filter(([muster]) => muster.test(GESAMTTEXT)) .map(([muster, name]) => `${name}: ${GESAMTTEXT.match(muster)?.[0] ?? ''}`); expect( befunde, 'Der Hilfedialog zeigt reinen Text. Was hier als Auszeichnung gemeint ist, ' + 'liest der Nutzende wörtlich.', ).toEqual([]); }); /* Gegenprobe: Die Wache darüber muss auch wirklich anschlagen. Ohne diesen Fall bliebe unbemerkt, wenn ein umgebautes Muster nie mehr trifft — dieselbe Überlegung wie bei der Wache über die Offline-Zusage. */ it('erkennt eine Auszeichnung, wenn eine dasteht', () => { const fett = /\*\*[^*]+\*\*/u; expect(fett.test('Der **Lernbericht** zeigt Ihren Stand.')).toBe(true); expect(fett.test('Der „Lernbericht“ zeigt Ihren Stand.')).toBe(false); }); }); describe('Handbuch – Tastenlisten', () => { it('nennt zu jeder Taste eine Wirkung', () => { for (const zeile of ALLE_TASTENZEILEN) { expect(zeile.tasten.length).toBeGreaterThan(0); expect(zeile.wirkung.trim().length).toBeGreaterThan(0); } }); it('sagt bei mehreren Tasten, wie sie zusammengehören', () => { /* „Strg + Plus“ heißt gleichzeitig, „1 bis 8“ heißt irgendeine davon. Ohne diese Angabe stünden beide gleich da – ein Zwischenraum sagt nichts, und Hörende bekämen ihn gar nicht mit. */ for (const zeile of ALLE_TASTENZEILEN.filter((z) => z.tasten.length > 1)) { expect(zeile.verbindung, `${zeile.tasten.join('/')}: keine Verbindung`).toBeDefined(); } }); it('führt beide Tastenlisten im Kapitel zur Tastaturbedienung', () => { /* Der eigentliche Zweck: Die Kürzel standen früher nur als JSX in `KuerzelHilfe`. Jetzt sind sie Daten, und dieser Test hält fest, dass das Handbuch sie auch tatsächlich zeigt – sonst wäre die gemeinsame Quelle zwar da, aber wirkungslos. */ const kapitel = HILFE_KAPITEL.find((k) => k.id === 'tastatur'); expect(kapitel).toBeDefined(); const listen = (kapitel?.bloecke ?? []) .filter((block) => block.art === 'tasten') .map((block) => block.zeilen); expect(listen).toContain(TASTENKUERZEL); expect(listen).toContain(TASTATURBEDIENUNG); }); }); describe('Handbuch – bleibt an der Anwendung', () => { it('nennt den tatsächlichen Sitzungsumfang', () => { /* Steht die Zahl als Text im Handbuch, läuft sie beim nächsten Umstellen von SITZUNGSUMFANG auseinander. Dieser Test schlägt dann fehl – und er schlägt auch fehl, wenn jemand die Zahl aus dem Handbuch entfernt. */ expect(GESAMTTEXT).toContain(String(SITZUNGSUMFANG)); }); it('nennt die tatsächliche Zahl der Simulationsprofile', () => { expect(GESAMTTEXT).toContain(String(PRUEFUNGSPROFILE.length)); }); it('nennt den Rückmeldeweg aus derselben Quelle wie „Über diese Software“', () => { /* Zwei Stellen mit derselben Adresse laufen auseinander; eine davon ist dann falsch, und niemand merkt es. Beide lesen aus KONTAKT. */ expect(GESAMTTEXT).toContain(KONTAKT.epost); expect(GESAMTTEXT).toContain(KONTAKT.name); expect(GESAMTTEXT).toContain(KONTAKT.frist); }); it('führt ein Kapitel, das den Meldeweg erklärt', () => { // Prüfschritt 9.2.2 verlangt einen auffindbaren Kanal – auch von hier aus. expect(HILFE_KAPITEL.map((kapitel) => kapitel.id)).toContain('melden'); }); /* Das Handbuch nannte drei der vier Ampelworte und ließ ausgerechnet das aus, das jeder Anfänger als Erstes sieht. Der Fehler war unsichtbar, weil die Worte als Zeichenkette dastanden statt aus `STUFE_WORT` zu kommen – genau die Bauweise, die der Modulkopf ausschließt. */ it('nennt alle vier Stufen der Prüfungsreife, und zwar aus der Quelle', () => { for (const wort of Object.values(STUFE_WORT)) { expect(GESAMTTEXT).toContain(wort); } }); it('nennt alle vier Stufen der Selbstbewertung aus derselben Quelle', () => { for (const wort of Object.values(BEWERTUNG_BEZEICHNUNG)) { expect(GESAMTTEXT).toContain(wort); } }); /* Die Liste unter der Musterantwort hieß bis 0.17.0 „Diese Kernelemente muss Ihre Antwort enthalten“. Das Handbuch darf die abgeschaffte Behauptung nicht weitertragen – und muss sagen, was die Liste wirklich ist, sonst liest sie jeder als Pflichtinhalt. */ it('erklärt die unterstrichenen Stellen, ohne sie zur Vorgabe zu machen', () => { expect(GESAMTTEXT).toContain('unterstrichen'); expect(GESAMTTEXT).not.toContain('muss Ihre Antwort enthalten'); }); it('nennt den Einstieg „Offene Fragen“', () => { expect(GESAMTTEXT).toContain('Offene Fragen'); }); /* Das Kapitel „Wenn etwas nicht funktioniert“ sagte bis 0.22.0, die Anwendung gehe „bis auf das Doppelte“ – sie geht bis 400 Prozent. Die Falschauskunft stand ausgerechnet dort, wo jemand mit zu kleiner Schrift nachschlägt: Wem 200 Prozent nicht genügen, der las schwarz auf weiß, dass mehr nicht geht. Der Satz kommt jetzt aus ANZEIGEGROESSEN, und dieser Test hält beide Richtungen fest – die richtige Zahl muss dastehen, und die alte Behauptung darf nicht zurückkehren. */ it('nennt die tatsächliche Obergrenze der Anzeigegröße', () => { const groesste = Math.max(...ANZEIGEGROESSEN); expect(GESAMTTEXT).toContain(`${String(groesste)} Prozent`); expect(GESAMTTEXT).not.toMatch(/bis auf das Doppelte/u); }); }); /* Die Software übt den theoretischen Teil der Sachkundeprüfung. Dass es daneben einen praktischen gibt, stand bis 0.22.0 nirgends in der Oberfläche – „praktisch“ kam im ganzen Handbuch nicht vor. Wer nur hiermit lernt, konnte glauben, der Fragenbogen sei die ganze Prüfung. PLAN.md 5.4 verlangt den Hinweis ausdrücklich; erfüllt war er nie. */ describe('Handbuch – sagt, was die Anwendung nicht abdeckt', () => { it('nennt den praktischen Teil der Prüfung und seine Rechtsgrundlage', () => { expect(GESAMTTEXT).toContain('praktischen Teil'); expect(GESAMTTEXT).toContain('§ 2 Absatz 3'); }); it('sagt, dass der praktische Teil hier weder geübt noch geprüft wird', () => { const ueberblick = HILFE_KAPITEL.find((kapitel) => kapitel.id === 'ueberblick'); expect(ueberblick).toBeDefined(); const text = (ueberblick?.bloecke ?? []).flatMap(texte).join('\n'); expect(text).toContain('praktische Teil'); expect(text).toMatch(/keine Software vermitteln/u); }); /* Die Ebene-1-Wache, und sie ist die billigste von allen. Bis Fassung 0.24.2 prüfte in dieser Datei **keine einzige** Zusicherung, ob eine Funktion im Handbuch überhaupt vorkommt. Nachgezählt fehlten neunzehn Funktionen ganz, darunter mehrere, die eigens für die Lernsteuerung gebaut worden waren: hartnäckige Fragen, verwandte Fragen, „Ich hatte geraten“, die Kernpunkte, die aufklappbaren Normtexte und die gesamte Prüfungsauswertung mit Themenanalyse und wiederkehrenden Lücken. Ein Rückfall wäre niemandem aufgefallen. Geprüft wird die **Beschriftung**, die auf dem Bildschirm steht — nicht der interne Name. Wer eine Beschriftung ändert, muss das Handbuch mitziehen; genau dafür ist diese Wache da. Die Liste ist bewusst kurz gehalten: Sie führt die Bedienelemente, deren Kenntnis über den Lernerfolg entscheidet, und nicht jede Zeichenkette der Oberfläche. */ it('nennt die lernsteuernden Funktionen beim Namen', () => { const beschriftungen = [ 'Weiterlernen', 'Kapitel wählen', 'Nur Fehler', 'Gemerkte Fragen', 'Offene Fragen', 'Hartnäckige Fragen', 'Diese Fragen üben', 'Verwandte Fragen', 'Ich hatte geraten', 'Im Gesetz nachlesen', 'Ergebnis nach Themenbereichen', 'Wiederkehrende Lücken', 'Fehler wiederholen', 'Ihr Lernstand im Einzelnen', 'Ihr Lernplan', ]; const fehlende = beschriftungen.filter((wort) => !GESAMTTEXT.includes(wort)); expect( fehlende, 'Diese Bedienelemente steuern, was der Lernende als Nächstes tut. Was das ' + 'Handbuch nicht beim Namen nennt, findet er nicht — und die Oberfläche ' + 'erklärt nirgends, wozu es da ist.', ).toEqual([]); }); it('ordnet jeder genannten Ansicht ein Kapitel zu, das es gibt', () => { /* Kontextsensitives F1: Wer mitten in der Simulation drückt, landete bis 0.22.0 am Anfang des Handbuchs und musste das passende von elf Kapiteln selbst heraussuchen. Eine Zuordnung, die auf ein umbenanntes Kapitel zeigt, fiele stillschweigend auf den Anfang zurück – der Fehler sähe aus wie „keine Zuordnung“. */ const kennungen = new Set(HILFE_KAPITEL.map((kapitel) => kapitel.id)); for (const [ansicht, kapitel] of Object.entries(HILFE_ZUR_ANSICHT)) { expect(kennungen, `Ansicht „${ansicht}“ zeigt auf kein Kapitel`).toContain(kapitel); } }); it('führt die Ansichten, in denen ein Kapitel wirklich hilft', () => { /* Bis Fassung 0.24.2 stand hier `expect(HILFE_ZUR_ANSICHT['start']) .toBeUndefined()`, und die Begründung lautete: Auf dem Startbildschirm sei das Inhaltsverzeichnis die richtige Antwort. Diese Begründung stand und fiel mit ihrer Voraussetzung — dass es kein Kapitel gibt, das die Frage des Startbildschirms beantwortet. Seit 0.25.0 gibt es „So kommen Sie durch“, und es beantwortet genau sie: was als Nächstes zu tun ist. Wer dort F1 drückt, sucht diesen Text und nicht ein Verzeichnis von zwölf Kapiteln. Die Zusage bleibt in der Sache dieselbe: Eine Ansicht bekommt ein Kapitel nur, wenn dieses Kapitel ihre Frage beantwortet. */ expect(Object.keys(HILFE_ZUR_ANSICHT)).toEqual( expect.arrayContaining(['start', 'sitzung', 'pruefung', 'pruefungswahl', 'suche']), ); expect(HILFE_ZUR_ANSICHT['start']).toBe('weg'); /* Das Sitzungsende führte bis 0.24.2 nach „wiedervorlage“ – ein Kapitel, das kein Wort über die dort angebotenen Anschlusswege verlor. */ expect(HILFE_ZUR_ANSICHT['auswertung']).toBe('lernsitzung'); }); it('wiederholt den Vorbehalt im Kapitel zur Simulation', () => { /* Wer eine Simulation besteht, ist am ehesten in Versuchung, sich für fertig zu halten – der Vorbehalt gehört deshalb auch dorthin, nicht nur in den Überblick. */ const simulation = HILFE_KAPITEL.find((kapitel) => kapitel.id === 'simulation'); expect(simulation).toBeDefined(); const text = (simulation?.bloecke ?? []).flatMap(texte).join('\n'); expect(text).toContain('praktischer Teil'); }); });