lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests services dateinamensvorschlag.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { suggestFileName } from '@/services/storage'; |
| 3 | import { createEmptyProject } from '@/domain/model/factory'; |
| 4 | import type { Project } from '@/domain/model/project'; |
| 5 | |
| 6 | /** |
| 7 | * Der Dateinamensvorschlag muss unter Windows anlegbar sein. |
| 8 | * |
| 9 | * Befunde 71 und 72 zur Fassung 5.10.0. Beide Faelle haengen |
| 10 | * an derselben Quelle: `str` in src/domain/model/schema.ts reicht jede |
| 11 | * Zeichenkette einer Projektdatei ungefiltert an `meta.name` und |
| 12 | * `meta.projectNumber` durch, und `suggestFileName` baut daraus den Namen, der |
| 13 | * ueber `bridge.saveFile({ defaultName })` unveraendert in den |
| 14 | * Speichern-Dialog geht - `path.basename` im Hauptprozess reinigt nichts nach. |
| 15 | * |
| 16 | * 71: Windows verbietet neben den neun druckbaren Zeichen \ / : * ? " < > | |
| 17 | * auch den ganzen Bereich U+0000 bis U+001F. Ersetzt wurden nur die neun; von |
| 18 | * den Steuerzeichen fielen allein \t \n \v \f \r auf, weil sie als Leerraum |
| 19 | * gelten. U+0001 oder U+0007 standen unveraendert im Vorschlag - unsichtbar |
| 20 | * fuer den Anwender, und `open()` scheitert damit unter Windows mit ENOENT. |
| 21 | * |
| 22 | * 72: Die Begrenzung schnitt mit `slice(0, 120)` nach UTF-16-Codeeinheiten. |
| 23 | * Faellt ein Zeichen ausserhalb der Grundebene auf die Schnittstelle, blieb die |
| 24 | * einsame hohe Ersatzhaelfte stehen; Windows legt daraufhin eine Datei mit |
| 25 | * U+FFFD an, und der zurueckgemeldete Pfad weicht von dem ab, was das Programm |
| 26 | * vorgeschlagen hat. |
| 27 | * |
| 28 | * Die Steuerzeichen stehen hier durchgaengig als Escape und nie als Zeichen: |
| 29 | * Im Quelltext waeren sie unsichtbar, und Git fuehrt eine Datei mit einem |
| 30 | * Nullbyte als Binaerdatei (dieselbe Begruendung wie in src/render/pdfSurface.ts). |
| 31 | */ |
| 32 | |
| 33 | const JETZT = new Date('2026-09-06T09:00:00.000Z'); |
| 34 | |
| 35 | function mitMeta(projectNumber: string, name: string): Project { |
| 36 | const basis = createEmptyProject('x', JETZT); |
| 37 | return { ...basis, meta: { ...basis.meta, projectNumber, name } }; |
| 38 | } |
| 39 | |
| 40 | /** Irgendein Steuerzeichen U+0000 bis U+001F. */ |
| 41 | // eslint-disable-next-line no-control-regex |
| 42 | const STEUERZEICHEN = /[\x00-\x1f]/; |
| 43 | |
| 44 | /** Eine Ersatzhaelfte ohne ihre Gegenhaelfte. */ |
| 45 | const EINSAME_ERSATZHAELFTE = |
| 46 | /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/; |
| 47 | |
| 48 | describe('Dateinamensvorschlag: Steuerzeichen (Befund 71)', () => { |
| 49 | it('ersetzt auch die nicht als Leerraum geltenden Steuerzeichen', () => { |
| 50 | // U+0007 und U+0001 - beide ueberlebten die Ersetzung der neun druckbaren |
| 51 | // Zeichen, und beide lehnt Windows in einem Dateinamen ab. |
| 52 | const bel = String.fromCharCode(0x07); |
| 53 | const soh = String.fromCharCode(0x01); |
| 54 | const name = suggestFileName(mitMeta('', `Knoten${bel}B7${soh} Nord`)); |
| 55 | |
| 56 | expect(name).toBe('Knoten-B7- Nord.lsap'); |
| 57 | expect(name).not.toMatch(STEUERZEICHEN); |
| 58 | }); |
| 59 | |
| 60 | it('laesst kein einziges Steuerzeichen von U+0000 bis U+001F stehen', () => { |
| 61 | const stehengeblieben: string[] = []; |
| 62 | for (let code = 0; code <= 0x1f; code += 1) { |
| 63 | const name = suggestFileName(mitMeta('', `A${String.fromCharCode(code)}B`)); |
| 64 | if (STEUERZEICHEN.test(name)) stehengeblieben.push(`U+${code.toString(16).padStart(4, '0')}`); |
| 65 | } |
| 66 | |
| 67 | expect(stehengeblieben).toEqual([]); |
| 68 | }); |
| 69 | |
| 70 | it('zieht Zeilenumbruch und Tabulator weiterhin zu einem Leerzeichen zusammen', () => { |
| 71 | // Die Zusage des Bestands bleibt: Leerraum wird zusammengezogen, nicht |
| 72 | // durch Bindestriche ersetzt. Deshalb laeuft die Ersetzung der |
| 73 | // Steuerzeichen NACH dem Zusammenziehen. |
| 74 | expect(suggestFileName(mitMeta('', 'Zeile eins\nZeile zwei\tdrei'))).toBe( |
| 75 | 'Zeile eins Zeile zwei drei.lsap', |
| 76 | ); |
| 77 | }); |
| 78 | }); |
| 79 | |
| 80 | describe('Dateinamensvorschlag: Grenze von 120 Zeichen (Befund 72)', () => { |
| 81 | it('zerteilt an der Grenze kein Zeichen ausserhalb der Grundebene', () => { |
| 82 | const lang = `${'K'.repeat(119)}\u{1F6A6} und noch etwas dahinter`; |
| 83 | |
| 84 | const name = suggestFileName(mitMeta('', lang)); |
| 85 | |
| 86 | expect(name).toBe(`${'K'.repeat(119)}\u{1F6A6}.lsap`); |
| 87 | expect(name).not.toMatch(EINSAME_ERSATZHAELFTE); |
| 88 | }); |
| 89 | |
| 90 | it('zaehlt die Grenze in Zeichen, nicht in Codeeinheiten', () => { |
| 91 | const lang = '\u{1F6A6}'.repeat(200); |
| 92 | |
| 93 | const name = suggestFileName(mitMeta('', lang)); |
| 94 | |
| 95 | expect([...name.slice(0, -'.lsap'.length)]).toHaveLength(120); |
| 96 | expect(name).not.toMatch(EINSAME_ERSATZHAELFTE); |
| 97 | }); |
| 98 | |
| 99 | it('begrenzt reinen Grundebenentext weiterhin auf 120 Zeichen', () => { |
| 100 | expect(suggestFileName(mitMeta('', 'K'.repeat(200)))).toBe(`${'K'.repeat(120)}.lsap`); |
| 101 | }); |
| 102 | }); |