import { describe, expect, it } from 'vitest'; import { suggestFileName } from '@/services/storage'; import { createEmptyProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /** * Der Dateinamensvorschlag muss unter Windows anlegbar sein. * * Befunde 71 und 72 zur Fassung 5.10.0. Beide Faelle haengen * an derselben Quelle: `str` in src/domain/model/schema.ts reicht jede * Zeichenkette einer Projektdatei ungefiltert an `meta.name` und * `meta.projectNumber` durch, und `suggestFileName` baut daraus den Namen, der * ueber `bridge.saveFile({ defaultName })` unveraendert in den * Speichern-Dialog geht - `path.basename` im Hauptprozess reinigt nichts nach. * * 71: Windows verbietet neben den neun druckbaren Zeichen \ / : * ? " < > | * auch den ganzen Bereich U+0000 bis U+001F. Ersetzt wurden nur die neun; von * den Steuerzeichen fielen allein \t \n \v \f \r auf, weil sie als Leerraum * gelten. U+0001 oder U+0007 standen unveraendert im Vorschlag - unsichtbar * fuer den Anwender, und `open()` scheitert damit unter Windows mit ENOENT. * * 72: Die Begrenzung schnitt mit `slice(0, 120)` nach UTF-16-Codeeinheiten. * Faellt ein Zeichen ausserhalb der Grundebene auf die Schnittstelle, blieb die * einsame hohe Ersatzhaelfte stehen; Windows legt daraufhin eine Datei mit * U+FFFD an, und der zurueckgemeldete Pfad weicht von dem ab, was das Programm * vorgeschlagen hat. * * Die Steuerzeichen stehen hier durchgaengig als Escape und nie als Zeichen: * Im Quelltext waeren sie unsichtbar, und Git fuehrt eine Datei mit einem * Nullbyte als Binaerdatei (dieselbe Begruendung wie in src/render/pdfSurface.ts). */ const JETZT = new Date('2026-09-06T09:00:00.000Z'); function mitMeta(projectNumber: string, name: string): Project { const basis = createEmptyProject('x', JETZT); return { ...basis, meta: { ...basis.meta, projectNumber, name } }; } /** Irgendein Steuerzeichen U+0000 bis U+001F. */ // eslint-disable-next-line no-control-regex const STEUERZEICHEN = /[\x00-\x1f]/; /** Eine Ersatzhaelfte ohne ihre Gegenhaelfte. */ const EINSAME_ERSATZHAELFTE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { it('ersetzt auch die nicht als Leerraum geltenden Steuerzeichen', () => { // U+0007 und U+0001 - beide ueberlebten die Ersetzung der neun druckbaren // Zeichen, und beide lehnt Windows in einem Dateinamen ab. const bel = String.fromCharCode(0x07); const soh = String.fromCharCode(0x01); const name = suggestFileName(mitMeta('', `Knoten${bel}B7${soh} Nord`)); expect(name).toBe('Knoten-B7- Nord.lsap'); expect(name).not.toMatch(STEUERZEICHEN); }); it('laesst kein einziges Steuerzeichen von U+0000 bis U+001F stehen', () => { const stehengeblieben: string[] = []; for (let code = 0; code <= 0x1f; code += 1) { const name = suggestFileName(mitMeta('', `A${String.fromCharCode(code)}B`)); if (STEUERZEICHEN.test(name)) stehengeblieben.push(`U+${code.toString(16).padStart(4, '0')}`); } expect(stehengeblieben).toEqual([]); }); it('zieht Zeilenumbruch und Tabulator weiterhin zu einem Leerzeichen zusammen', () => { // Die Zusage des Bestands bleibt: Leerraum wird zusammengezogen, nicht // durch Bindestriche ersetzt. Deshalb laeuft die Ersetzung der // Steuerzeichen NACH dem Zusammenziehen. expect(suggestFileName(mitMeta('', 'Zeile eins\nZeile zwei\tdrei'))).toBe( 'Zeile eins Zeile zwei drei.lsap', ); }); }); describe('Dateinamensvorschlag: Grenze von 120 Zeichen (Befund 72)', () => { it('zerteilt an der Grenze kein Zeichen ausserhalb der Grundebene', () => { const lang = `${'K'.repeat(119)}\u{1F6A6} und noch etwas dahinter`; const name = suggestFileName(mitMeta('', lang)); expect(name).toBe(`${'K'.repeat(119)}\u{1F6A6}.lsap`); expect(name).not.toMatch(EINSAME_ERSATZHAELFTE); }); it('zaehlt die Grenze in Zeichen, nicht in Codeeinheiten', () => { const lang = '\u{1F6A6}'.repeat(200); const name = suggestFileName(mitMeta('', lang)); expect([...name.slice(0, -'.lsap'.length)]).toHaveLength(120); expect(name).not.toMatch(EINSAME_ERSATZHAELFTE); }); it('begrenzt reinen Grundebenentext weiterhin auf 120 Zeichen', () => { expect(suggestFileName(mitMeta('', 'K'.repeat(200)))).toBe(`${'K'.repeat(120)}.lsap`); }); });