lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests export matrixLangname.test.ts

9,0 KB Rohdatei
tests/export/matrixLangname.test.ts — 246 Zeilen
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 });