/** * Prüfhilfen für Nutzlasten an der IPC-Grenze. * * Jede Nutzlast aus dem Renderer ist unbekannt, bis sie geprüft wurde. Die * hier gesammelten Funktionen nehmen deshalb `unknown` entgegen, liefern * einen engen Typ zurück und brechen sonst mit einer deutschen Meldung ab. * * Bewusst ein eigenes Modul: Lernstand und Prüfungssimulation prüfen ihre * Eingaben nach denselben Regeln, und zwei Kopien derselben Regel driften * mit der Zeit auseinander. */ /** Bricht die Verarbeitung mit einer sprechenden deutschen Meldung ab. */ export function abweisen(nachricht: string): never { throw new Error(nachricht); } /** Stellt sicher, dass die Nutzlast ein einfaches Objekt ist. */ export function nutzlast(wert: unknown, name: string): Record { if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) { abweisen(`Ungültige Anfrage: ${name} muss ein Objekt sein.`); } return wert as Record; } export function ganzeZahl(wert: unknown, name: string, min: number, max: number): number { if (typeof wert !== 'number' || !Number.isInteger(wert) || wert < min || wert > max) { abweisen( `Ungültige Anfrage: ${name} muss eine ganze Zahl zwischen ${String(min)} und ${String(max)} sein.`, ); } return wert; } /** Wie {@link ganzeZahl}, lässt aber Nachkommastellen zu (etwa für Quoten). */ export function endlicheZahl(wert: unknown, name: string, min: number, max: number): number { if (typeof wert !== 'number' || !Number.isFinite(wert) || wert < min || wert > max) { abweisen( `Ungültige Anfrage: ${name} muss eine Zahl zwischen ${String(min)} und ${String(max)} sein.`, ); } return wert; } export function wahrheitswert(wert: unknown, name: string, standard: boolean): boolean { if (wert === undefined || wert === null) { return standard; } if (typeof wert !== 'boolean') { abweisen(`Ungültige Anfrage: ${name} muss ein Wahrheitswert sein.`); } return wert; } export function textliste(wert: unknown, name: string): string[] { if (wert === undefined || wert === null) { return []; } if (!Array.isArray(wert)) { abweisen(`Ungültige Anfrage: ${name} muss eine Liste von Zeichenketten sein.`); } return (wert as readonly unknown[]).map((eintrag) => { if (typeof eintrag !== 'string') { abweisen(`Ungültige Anfrage: ${name} enthält einen Eintrag, der keine Zeichenkette ist.`); } return eintrag; }); } /** Obergrenze einer bereinigten Meldung, in Graphemen. */ const MAX_MELDUNGSLAENGE = 80; /** * Zeichen, die in einer Meldung nichts zu suchen haben. * * Neben den klassischen Steuerzeichen (C0 und DEL) auch: * * - **C1** (U+0080–U+009F) – in manchen Terminals als Steuerbefehl gedeutet. * - **U+2028/U+2029** – Zeilen- und Absatztrenner; sie brechen eine Logzeile * auf, ohne wie ein Zeilenumbruch auszusehen. * - **Bidirektionale Steuerzeichen** (U+200E/U+200F, U+202A–U+202E, * U+2066–U+2069) – mit ihnen lässt sich die Anzeigereihenfolge umkehren. * Eine Meldung kann dann etwas völlig anderes zeigen, als sie enthält. * - **U+FEFF** – unsichtbar und in Textvergleichen leicht zu übersehen. */ function istGefaehrlich(punkt: number): boolean { return ( punkt < 0x20 || punkt === 0x7f || (punkt >= 0x80 && punkt <= 0x9f) || punkt === 0x200e || punkt === 0x200f || (punkt >= 0x202a && punkt <= 0x202e) || punkt === 0x2028 || punkt === 0x2029 || (punkt >= 0x2066 && punkt <= 0x2069) || punkt === 0xfeff ); } /** * Ersetzt Steuerzeichen und kürzt auf 80 Zeichen. * * Fremdeingaben landen in Fehlermeldungen, im Protokoll und in Profilnamen. * Ohne diese Reinigung könnte eine geschickt gewählte Zeichenkette Log-Zeilen * oder Terminalausgaben verfälschen – im Fall der bidirektionalen * Steuerzeichen sogar so, dass die Anzeige das Gegenteil des Inhalts zeigt. * * Gekürzt wird nach **Graphemen**, nicht nach UTF-16-Einheiten und auch nicht * nach Codepoints. Nach UTF-16-Einheiten bliebe womöglich ein halbes * Surrogatpaar zurück; nach Codepoints zerfiele eine zusammengesetzte * Darstellung – eine Flagge, eine Familie, ein Buchstabe mit Akzent – in ihre * Bestandteile und zeigte etwas anderes an als vorher. */ const SEGMENTIERER = new Intl.Segmenter('de', { granularity: 'grapheme' }); export function entschaerft(wert: unknown, ersatz = '?'): string { const text = typeof wert === 'string' ? wert : String(wert); let sauber = ''; let gezaehlt = 0; for (const { segment } of SEGMENTIERER.segment(text)) { if (gezaehlt >= MAX_MELDUNGSLAENGE) { break; } gezaehlt += 1; /* Ein Graphem kann aus mehreren Codepoints bestehen. Steckt auch nur ein gefährlicher darin, wird das ganze Graphem ersetzt – ein bidirektionales Steuerzeichen wirkt sonst weiter, nur eben eingebettet. */ let gefaehrlich = false; for (const zeichen of segment) { if (istGefaehrlich(zeichen.codePointAt(0) ?? 0)) { gefaehrlich = true; break; } } sauber += gefaehrlich ? ersatz : segment; } return sauber; } /** Fisher-Yates. Liefert eine neue Liste, die Vorlage bleibt unberührt. */ export function gemischt(werte: readonly T[], zufall: () => number): T[] { const kopie = [...werte]; for (let i = kopie.length - 1; i > 0; i -= 1) { const j = Math.floor(zufall() * (i + 1)); const a = kopie[i]; const b = kopie[j]; if (a !== undefined && b !== undefined) { kopie[i] = b; kopie[j] = a; } } return kopie; }