lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests export matrixLangname.test.ts
| 1 | import { inflateSync } from 'node:zlib'; |
| 2 | import { describe, expect, it } from 'vitest'; |
| 3 | import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf'; |
| 4 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 5 | import { validateProject } from '@/domain/validation'; |
| 6 | import { |
| 7 | createArm, |
| 8 | createConflict, |
| 9 | createEmptyProject, |
| 10 | createPhase, |
| 11 | createSignalGroup, |
| 12 | } from '@/domain/model/factory'; |
| 13 | import type { Conflict, Project, SignalGroup } from '@/domain/model/project'; |
| 14 | |
| 15 | /* |
| 16 | * BEFUND 8 (Fassung 5.11.0): Ein langer Signalgruppenname brachte die ganze |
| 17 | * PDF-Ausgabe mit RangeError zum Absturz. |
| 18 | * |
| 19 | * Die Wache in `matrixSatz` verkleinerte bei zu hohem Tabellenkopf nur die Zahl |
| 20 | * der Spalten je Ausschnitt und verliess die Schleife bei einer einzigen Spalte |
| 21 | * OHNE noch einmal zu pruefen, ob Kopf und Koerperzeile dort ueberhaupt auf ein |
| 22 | * Blatt passen. Diesen Fall konnte sie mit ihrem einzigen Hebel gar nicht |
| 23 | * erreichen: Die Hoehe der Koerperzeile entsteht aus dem Zeilenkopf, der den |
| 24 | * vollen Namen in der begrenzten Namensspalte traegt, und weniger Spalten |
| 25 | * machen ihn nicht niedriger. |
| 26 | * |
| 27 | * Was der Anwender sonst erlebt: Er fuegt in das Namensfeld einer Signalgruppe |
| 28 | * einen langen Text ein (das Feld hat keine Laengengrenze) und erzeugt die |
| 29 | * Unterlage. jspdf-autotable laeuft in `printFullRow` in die Endlosteilung - |
| 30 | * Hunderte Meldungen "minimum height is larger than page height", dann |
| 31 | * "RangeError: Maximum call stack size exceeded". Der Pruefbericht meldet dabei |
| 32 | * 0 Fehler, `exportBlocked` ist false; es entsteht keine Ausgabe, auch der |
| 33 | * Pruefbericht nicht. Genau diesen Einsturz zu verhindern ist die im Kommentar |
| 34 | * ueber der Schranke gegebene Zusage. |
| 35 | * |
| 36 | * Was die Behebung NICHT tun darf: die Zuordnungszeile kuerzen. Sie ist der |
| 37 | * Nachweis, mit dem sich ein einzelnes Blatt ohne ein zweites aufloesen laesst |
| 38 | * (pdfZwischenzeitenmatrix.test.ts, "loest die Zuordnung der Spaltenkoepfe auf |
| 39 | * JEDEM Blatt auf"). Gekuerzt wird der ZEILENKOPF, in dem der Name ohnehin ein |
| 40 | * zweites Mal steht. |
| 41 | */ |
| 42 | |
| 43 | const FEST = new Date('2026-01-01T12:00:00Z'); |
| 44 | |
| 45 | const PAGE_MARGIN = 36; |
| 46 | const FOOTER_HEIGHT = 26; |
| 47 | /** Unterkante des Satzspiegels, in PDF-Koordinaten (Ursprung unten). */ |
| 48 | const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT; |
| 49 | /** Grundlinie der Fusszeile - der einzige Text, der darunter stehen darf. */ |
| 50 | const FUSSZEILE_Y = PAGE_MARGIN + 6; |
| 51 | |
| 52 | const NUR_MATRIX: PdfOptions = { |
| 53 | ...DEFAULT_PDF_OPTIONS, |
| 54 | includeSignalGroups: false, |
| 55 | includePhases: false, |
| 56 | includePlan: false, |
| 57 | includePerformance: false, |
| 58 | includeReport: false, |
| 59 | includeCalculationDetails: false, |
| 60 | includeSources: false, |
| 61 | }; |
| 62 | |
| 63 | /** Das Schlusswort des langen Namens - daran haengt die Gegenprobe. */ |
| 64 | const SCHLUSSWORT = 'Schlussmerkmal'; |
| 65 | |
| 66 | /** Ein Name der gewuenschten Laenge; SignalGroup.name ist nicht begrenzt. */ |
| 67 | function langerName(laenge: number): string { |
| 68 | let text = 'Kraftfahrzeuge Nordzufahrt Hauptstraße geradeaus und rechts abbiegend '; |
| 69 | while (text.length < laenge - SCHLUSSWORT.length - 1) text += 'Zusatzangabe '; |
| 70 | return `${text.slice(0, laenge - SCHLUSSWORT.length - 1)} ${SCHLUSSWORT}`; |
| 71 | } |
| 72 | |
| 73 | interface Stueck { |
| 74 | readonly blatt: number; |
| 75 | readonly y: number; |
| 76 | readonly text: string; |
| 77 | } |
| 78 | |
| 79 | function entpacke(roh: string, von: number): string | null { |
| 80 | const anfang = roh.indexOf('stream', von); |
| 81 | if (anfang < 0) return null; |
| 82 | const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7); |
| 83 | const ende = roh.indexOf('endstream', start); |
| 84 | if (ende < 0) return null; |
| 85 | try { |
| 86 | return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); |
| 87 | } catch { |
| 88 | return null; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | function textstuecke(bytes: Uint8Array): readonly Stueck[] { |
| 93 | const roh = new TextDecoder('latin1').decode(bytes); |
| 94 | const raus: Stueck[] = []; |
| 95 | let blatt = 0; |
| 96 | |
| 97 | for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) { |
| 98 | const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`); |
| 99 | const inhalt = objekt < 0 ? null : entpacke(roh, objekt); |
| 100 | if (inhalt === null) continue; |
| 101 | blatt += 1; |
| 102 | let y = 0; |
| 103 | let abstand = 0; |
| 104 | for (const roheZeile of inhalt.split('\n')) { |
| 105 | const zeile = roheZeile.trim(); |
| 106 | const tl = /^(-?[\d.]+) TL$/.exec(zeile); |
| 107 | if (tl) { |
| 108 | abstand = Number(tl[1]); |
| 109 | continue; |
| 110 | } |
| 111 | const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile); |
| 112 | if (td) { |
| 113 | y = Number(td[2]); |
| 114 | continue; |
| 115 | } |
| 116 | let rest = zeile; |
| 117 | while (rest.startsWith('T*')) { |
| 118 | y -= abstand; |
| 119 | rest = rest.slice(2).trim(); |
| 120 | } |
| 121 | const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest); |
| 122 | if (tj) raus.push({ blatt, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') }); |
| 123 | } |
| 124 | } |
| 125 | return raus; |
| 126 | } |
| 127 | |
| 128 | /** Ein Knotenpunkt mit sechs Signalgruppen; eine traegt den langen Namen. */ |
| 129 | function projekt(laenge: number): Project { |
| 130 | const basis = createEmptyProject('Pruefkreuzung', FEST, 'knotenpunkt'); |
| 131 | const arme = ['Nord', 'Ost', 'Süd', 'West'].map((r) => createArm(r, r, 2, 50)); |
| 132 | |
| 133 | const groups: SignalGroup[] = []; |
| 134 | for (let i = 0; i < 6; i += 1) { |
| 135 | groups.push( |
| 136 | createSignalGroup({ |
| 137 | name: i === 2 ? langerName(laenge) : `K${i + 1}`, |
| 138 | mode: 'kfz', |
| 139 | armId: arme[i % 4]?.id ?? null, |
| 140 | index: i, |
| 141 | }), |
| 142 | ); |
| 143 | } |
| 144 | |
| 145 | const conflicts: Conflict[] = []; |
| 146 | for (let i = 0; i < groups.length; i += 1) { |
| 147 | for (let j = 0; j < groups.length; j += 1) { |
| 148 | if (i === j) continue; |
| 149 | conflicts.push({ |
| 150 | ...createConflict(groups[i]!.id, groups[j]!.id), |
| 151 | clearingDistance: 18 + (i % 7), |
| 152 | enteringDistance: 6 + (j % 5), |
| 153 | }); |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | const p1 = createPhase( |
| 158 | 'Phase 1', |
| 159 | groups.slice(0, 3).map((g) => g.id), |
| 160 | ); |
| 161 | const p2 = createPhase( |
| 162 | 'Phase 2', |
| 163 | groups.slice(3).map((g) => g.id), |
| 164 | ); |
| 165 | |
| 166 | return { |
| 167 | ...basis, |
| 168 | intersection: { ...basis.intersection, arms: arme }, |
| 169 | signalGroups: groups, |
| 170 | conflicts, |
| 171 | phases: [p1, p2], |
| 172 | program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, |
| 173 | }; |
| 174 | } |
| 175 | |
| 176 | function unterlage(laenge: number): Uint8Array { |
| 177 | const project = projekt(laenge); |
| 178 | const plan = buildSignalPlan(project); |
| 179 | return buildProjectPdf(project, plan, validateProject(project, plan, FEST), NUR_MATRIX, FEST); |
| 180 | } |
| 181 | |
| 182 | describe('Befund 8 - sehr langer Signalgruppenname in der Matrix', () => { |
| 183 | it('haelt die Zuordnung im wiederholten Tabellenkopf, solange sie dorthin passt', () => { |
| 184 | // 4.000 Zeichen: Der Zeilenkopf muss schon gekuerzt werden (in der 180 pt |
| 185 | // breiten Namensspalte waeren es rund 100 Zeilen), die Zuordnung passt aber |
| 186 | // noch in den Kopf - erst der zweite Hebel greift, nicht beide. |
| 187 | const stuecke = textstuecke(unterlage(4000)); |
| 188 | expect( |
| 189 | stuecke.filter((s) => s.text.includes('Nummern der Spaltenköpfe')).length, |
| 190 | 'die Zuordnung steht ueber jedem Ausschnitt', |
| 191 | ).toBeGreaterThan(0); |
| 192 | expect( |
| 193 | stuecke.filter((s) => s.text.includes('lässt sich deshalb nicht auf jedem Blatt')).length, |
| 194 | 'der Ausweichweg wird hier noch nicht gebraucht', |
| 195 | ).toBe(0); |
| 196 | }, 180_000); |
| 197 | |
| 198 | it('setzt die Zuordnung ungekuerzt vor den Ausschnitt, wenn sie hoeher ist als ein Blatt', () => { |
| 199 | // 20.000 Zeichen ergeben allein fuer EINE Zuordnungszeile mehr Zeilen, als |
| 200 | // ein Blatt traegt - der Hebel "weniger Spalten je Ausschnitt" ist damit |
| 201 | // erschoepft. Gekuerzt wird sie trotzdem nicht: Sie ist der Nachweis. |
| 202 | const stuecke = textstuecke(unterlage(20000)); |
| 203 | expect( |
| 204 | stuecke.filter((s) => s.text.includes('lässt sich deshalb nicht auf jedem Blatt')).length, |
| 205 | 'der Ausdruck sagt nicht, wo die Zuordnung steht', |
| 206 | ).toBeGreaterThan(0); |
| 207 | expect( |
| 208 | stuecke.filter((s) => s.text.startsWith('Zuordnung der Spaltenköpfe für Ausschnitt')).length, |
| 209 | 'die Zuordnung steht nicht vor den Ausschnitten', |
| 210 | ).toBeGreaterThan(0); |
| 211 | }, 180_000); |
| 212 | |
| 213 | for (const laenge of [7500, 20000]) { |
| 214 | it(`erzeugt die Unterlage bei einem Namen von ${laenge} Zeichen`, () => { |
| 215 | const bytes = unterlage(laenge); |
| 216 | expect(bytes.byteLength).toBeGreaterThan(0); |
| 217 | |
| 218 | const stuecke = textstuecke(bytes); |
| 219 | |
| 220 | // Der volle Name bleibt aufloesbar: Sein Schlusswort steht in der |
| 221 | // Zuordnung, nicht nur der Anfang. |
| 222 | expect( |
| 223 | stuecke.filter((s) => s.text.includes(SCHLUSSWORT)).length, |
| 224 | 'das Schlusswort des Namens fehlt im Dokument', |
| 225 | ).toBeGreaterThan(0); |
| 226 | |
| 227 | // Der Zeilenkopf ist gekuerzt - sichtbar, nicht stillschweigend. |
| 228 | expect( |
| 229 | stuecke.filter((s) => s.text.includes('...')).length, |
| 230 | 'die Kuerzung des Zeilenkopfs ist nicht mit Auslassungszeichen gekennzeichnet', |
| 231 | ).toBeGreaterThan(0); |
| 232 | expect( |
| 233 | stuecke.filter((s) => s.text.includes('Die Zeilenköpfe dieser Matrix sind gekürzt')).length, |
| 234 | 'die Kuerzung wird im Ausdruck nicht erklaert', |
| 235 | ).toBeGreaterThan(0); |
| 236 | |
| 237 | // Und nichts laeuft in den Fusssteg oder aus dem Blatt. |
| 238 | expect( |
| 239 | stuecke |
| 240 | .filter((s) => s.y < SATZSPIEGEL_UNTEN && Math.abs(s.y - FUSSZEILE_Y) > 0.5) |
| 241 | .map((s) => `Blatt ${s.blatt}, y ${s.y.toFixed(2)}: "${s.text.slice(0, 60)}"`), |
| 242 | 'kein Text unterhalb des Satzspiegels ausser der Fusszeile', |
| 243 | ).toEqual([]); |
| 244 | }, 180_000); |
| 245 | } |
| 246 | }); |