lsa-planer

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

/ tests export ausdruckEchteAnlagenarten.test.ts

15,7 KB Rohdatei
tests/export/ausdruckEchteAnlagenarten.test.ts — 340 Zeilen
1 import { inflateSync } from 'node:zlib';
2 import { describe, expect, it } from 'vitest';
3 import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf';
4 import { FASSUNGSANGABE } from '@/fassung';
5 import { buildSignalPlan } from '@/domain/plan/signalPlan';
6 import { validateProject } from '@/domain/validation';
7 import { createStandardIntersectionProject, defaultVehicleClass } from '@/domain/model/factory';
8 import { ASSISTENT_VORGABE, projektAusAngaben, type Anlagenform } from '@/ui/assistent';
9 import type { Anlagenart, Project } from '@/domain/model/project';
10
11 /**
12 * Die Planunterlage an der ECHTEN Geometrie der Anlagenart.
13 *
14 * DER BEFUND, DER DIESE DATEI AUSGELOEST HAT, war kein falsches Ergebnis,
15 * sondern ein Pruefstueck, das die Frage nicht stellte:
16 * `tests/export/pdfAnlagenart.test.ts` nimmt einen vierarmigen Standard-
17 * knotenpunkt und ueberschreibt an ihm ein einziges Feld - `anlagenart`. Alles
18 * andere bleibt ein Knotenpunkt: vier Arme, sechs Signalgruppen, zwoelf
19 * Konfliktbeziehungen, Raeumwege um 22 m, zwei Fussgaengerfurten. Was dabei
20 * geprueft wird, sind die Stellen des Ausdrucks, die an der ANLAGENART
21 * haengen - und die sind damit auch richtig geprueft. Was dabei NICHT geprueft
22 * wird, ist alles, was an der Geometrie haengt, und das ist der groessere Teil.
23 *
24 * GEMESSEN, was der ueberschriebene Knotenpunkt als einstreifige
25 * Verkehrsfuehrung ausdruckt: "Die Engstelle ist mit 22 m erfasst" - 22 m ist
26 * in Wahrheit der Raeumweg eines Knotenpunktstroms. Dazu "Bis 50 m kann nach
27 * RSA 21 die Regelung nach Paragraf 6 StVO genuegen", also die Auskunft, die
28 * Baustellenampel sei entbehrlich. Dazu ein Typklassensatz, der "kreuzende
29 * Stroeme: K1, K2, K3, K4" nennt - eine einstreifige Wechselverkehrsfuehrung
30 * hat zwei gegenlaeufige Stroeme und definitionsgemaess keine kreuzenden. Und
31 * dazu der ganze Abschnitt "Fussgaenger und Radverkehr" samt Furtabsaetzen, in
32 * einer Unterlage ohne eine einzige Furt.
33 *
34 * Kein einziger dieser Saetze ist ein Fehler des Programms: Es druckt genau
35 * das, was in dem Projekt steht, das man ihm gibt. Der Fehler waere zu
36 * glauben, damit sei der Weg "Assistent -> Ausdruck" fuer diese Anlagenarten
37 * geprueft. Er war es fuer keine der beiden.
38 *
39 * DIESE DATEI PRUEFT DESHALB DEN WEG, DEN EIN ANWENDER GEHT: das Projekt aus
40 * dem gefuehrten Einstieg, wie er es anlegt, durch Plan, Pruefbericht und
41 * Ausdruck. Die Anlagenartstellen bleiben, wo sie sind - in
42 * `pdfAnlagenart.test.ts`; hier steht, was nur an der echten Geometrie zu
43 * sehen ist.
44 */
45
46 const FIXED_DATE = new Date('2026-01-01T12:00:00Z');
47
48 /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */
49 function pdfText(bytes: Uint8Array): string {
50 const roh = new TextDecoder('latin1').decode(bytes);
51 const teile: string[] = [];
52 const suche = /stream\r?\n/g;
53 let treffer: RegExpExecArray | null;
54
55 while ((treffer = suche.exec(roh)) !== null) {
56 const start = treffer.index + treffer[0].length;
57 const ende = roh.indexOf('endstream', start);
58 if (ende < 0) continue;
59 let inhalt: string;
60 try {
61 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
62 } catch {
63 continue;
64 }
65 for (const stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) {
66 teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')')));
67 }
68 }
69 // Lange Absaetze sind auf Zeilen umbrochen; fuer die Suche wieder
70 // zusammenziehen, sonst findet contains() den halben Satz nicht.
71 return teile.join(' ').replace(/\s+/g, ' ');
72 }
73
74 /** Das Projekt, das der gefuehrte Einstieg mit seinen Vorgaben anlegt. */
75 function ausDemAssistenten(form: Anlagenform): Project {
76 return projektAusAngaben({ ...ASSISTENT_VORGABE, form }, 'Pruefstueck', FIXED_DATE);
77 }
78
79 /** Der ueberschriebene Knotenpunkt - das Pruefstueck, um das es hier geht. */
80 function ueberschrieben(anlagenart: Anlagenart): Project {
81 const basis = createStandardIntersectionProject('Hauptstrasse / Bahnhofstrasse', FIXED_DATE);
82 return { ...basis, anlagenart };
83 }
84
85 function ausdruck(project: Project): string {
86 const plan = buildSignalPlan(project);
87 const report = validateProject(project, plan, FIXED_DATE);
88 return pdfText(buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE));
89 }
90
91 describe('Fussgaengerschutzanlage - der Ausdruck an einer Fahrbahn mit einer Furt', () => {
92 const text = ausdruck(ausDemAssistenten('fussgaengerschutzanlage'));
93
94 it('druckt die zwei Signalgruppen, die es wirklich gibt', () => {
95 // Der ueberschriebene Knotenpunkt fuehrt hier sechs. Die Zahl steht auf
96 // dem Deckblatt und ist das erste, was ein Pruefer liest.
97 expect(text).toContain('Anzahl Signalgruppen 2');
98 // K3 und K4 gibt es an einer Fussgaengerschutzanlage nicht - und ihre
99 // Namen stehen im Ausdruck des ueberschriebenen Knotenpunkts.
100 expect(text, 'eine Signalgruppe aus der Knotenpunktvorlage').not.toContain('K3');
101 });
102
103 it('rechnet die Furtregel mit der eingegebenen Fahrbahnbreite', () => {
104 /*
105 * DIE ZAHL, UM DIE ES GEHT. Bei einer Fussgaengerschutzanlage ist die
106 * Fahrbahnbreite die einzige Massangabe des Assistenten - aus ihr folgen
107 * der Raeumweg der Furt und ueber den Haltlinienabstand der Raeumweg der
108 * Fahrzeuge. Der ueberschriebene Knotenpunkt bringt stattdessen die 12 m
109 * seiner Vorlage mit, und der Ausdruck rechnet die Furtregel damit durch.
110 */
111 expect(text).toContain('7,00 m');
112 expect(text, 'die Furtbreite der Knotenpunktvorlage').not.toContain('halbe Furt 6,00 m');
113 });
114
115 it('nennt die Phasen so, wie der Assistent sie anlegt', () => {
116 /*
117 * MIT BINDESTRICH, obwohl der Assistent einen Gedankenstrich setzt
118 * ("Phase 1 – Fahrzeuge", src/ui/assistent.ts). Das ist keine Eigenheit
119 * des Auslesens, sondern eine Entscheidung des Programms: `toWinAnsi` in
120 * src/render/pdfSurface.ts ersetzt Gedanken- und Geviertstrich durch den
121 * Bindestrich, weil die Schrift des Ausdrucks sie nicht fuehrt. Auf dem
122 * Papier steht deshalb etwas anderes als auf dem Bildschirm - hier
123 * festgehalten, damit es nicht unbemerkt umschlaegt.
124 */
125 expect(text).toContain('Phase 1 - Fahrzeuge');
126 expect(text).toContain('Phase 2 - Fußgänger');
127 expect(text, 'der Gedankenstrich hat es doch in den Ausdruck geschafft').not.toContain(
128 'Phase 1 – Fahrzeuge',
129 );
130 });
131 });
132
133 describe('Einstreifige Verkehrsfuehrung - der Ausdruck an einer Engstelle', () => {
134 const text = ausdruck(ausDemAssistenten('einstreifig'));
135
136 it('nennt die Engstelle mit ihrer wirklichen Laenge', () => {
137 /*
138 * Die Laenge der Engstelle ist an dieser Anlagenart die tragende Groesse:
139 * Sie IST der Raeumweg beider Richtungen, sie bestimmt die Zwischenzeit
140 * und ueber sie die Umlaufzeit. Der Assistent legt 120 m an
141 * (ASSISTENT_VORGABE.engstellenlaenge); der ueberschriebene Knotenpunkt
142 * bringt 22 m mit, und der Ausdruck schreibt sie als Engstellenlaenge
143 * hin.
144 */
145 expect(text).toContain(`Die Engstelle ist mit ${ASSISTENT_VORGABE.engstellenlaenge} m erfasst`);
146 });
147
148 it('bescheinigt der Anlage nicht, sie sei entbehrlich', () => {
149 /*
150 * Der gefaehrlichste Satz des ueberschriebenen Ausdrucks. Bis 50 m nennt
151 * die RSA 21 die Regelung nach Paragraf 6 StVO als Moeglichkeit - bei 22 m
152 * Raeumweg schlaegt die Regel an und bescheinigt einer Baustellenampel,
153 * dass es sie nicht braucht. Bei 120 m Engstelle darf davon nichts
154 * dastehen.
155 */
156 expect(text).not.toContain('Signalregelung nicht zwingend');
157 expect(text).not.toContain('Vorrang des Gegenverkehrs');
158 });
159
160 it('schreibt der Anlage keine Typklasse D vor', () => {
161 /*
162 * Die Regel zur Typklasse zaehlt Fussgaengergruppen und kreuzende Kfz-
163 * Stroeme. Eine einstreifige Wechselverkehrsfuehrung hat zwei
164 * GEGENLAEUFIGE Stroeme und definitionsgemaess keine kreuzenden - der
165 * ueberschriebene Knotenpunkt bringt vier Kfz-Gruppen und zwei Furten mit
166 * und laesst die Regel deshalb "Typklasse D zu prüfen" schreiben.
167 *
168 * Geprueft wird der Satz und nicht das Wort: "kreuzende Ströme" steht
169 * auch im richtigen Ausdruck - dort aber in der Verneinung
170 * ("... verlangten Fußgängersignale oder kreuzende Ströme Typ D").
171 *
172 * Der Wortlaut hat sich am 12.09.2026 geaendert: Bis dahin schrieb die
173 * Regel "Typ D erforderlich", also eine Feststellung ueber ein Regelwerk,
174 * das niemand aufgeschlagen hat (siehe
175 * tests/domain/regelwerksaussageOhneAbgleich.test.ts).
176 */
177 expect(text).not.toContain('Typklasse D zu prüfen');
178 expect(text).not.toContain('Fußgängergruppen');
179 });
180
181 it('druckt die Furtabsaetze nicht, die es hier nicht gibt', () => {
182 /*
183 * An dieser Anlage gibt es keine Furt. Der ueberschriebene Knotenpunkt
184 * bringt zwei mit, und der Ausdruck erklaert dann in einer Unterlage ohne
185 * eine einzige Furt, wie das Programm Furten rechnet.
186 *
187 * Geprueft wird der Absatz und nicht die Ueberschrift: "Fußgänger und
188 * Radverkehr" steht auch im richtigen Ausdruck - in den Erlaeuterungen zu
189 * Raeumgeschwindigkeit, Mindestfreigabezeit und Bewertungstafel, und die
190 * gelten unabhaengig davon, ob die Anlage Fussgaenger fuehrt.
191 */
192 expect(text).not.toContain('Eine Furt mit Mittelinsel ist über die Insel hinweg zu vermessen');
193 });
194
195 it('rechnet die Zwischenzeit aus der Engstellenlaenge', () => {
196 /*
197 * Der Raeumweg sr, wie er im Rechenweg der Zwischenzeitenmatrix steht:
198 * 120,0 m - der Abstand der Haltlinien, OHNE Fahrzeuglaenge. Daraus
199 * tr = 10,8 s bei 40 km/h und tue = 4 s; eine Einfahrzeit wird nicht
200 * abgezogen. tz = 14,8 s, aufgerundet 15 s (RiLSA 2015, Abschnitt 5.2.2).
201 *
202 * NACHGEZOGEN MIT 5.27.0: Bis dahin standen dort 126,0 m - 120 m Engstelle
203 * zuzueglich 6 m Laengenzuschlag des Pkw -, und die Rechnung lief nach den
204 * Knotenpunktregeln (tr = 12,6 s bei 10,0 m/s, tue = 3 s Gelbzeit,
205 * tz = 15,6 -> 16 s).
206 *
207 * Die Zahl steht hier, weil sie ohne die echte Geometrie nicht entstehen
208 * kann: Die groesste Zwischenzeit des ueberschriebenen Knotenpunkts
209 * betraegt gemessen 10 s, die dieser Engstelle 15 s. Der Fall, um den es
210 * an einer Engstelle ueberhaupt geht - ein langer Raeumweg treibt die
211 * Umlaufzeit -, kommt in der Vorlage gar nicht vor.
212 */
213 expect(text).toContain('120,0 m');
214 expect(text).not.toContain('126,0 m');
215 /*
216 * UND DIE FUSSNOTE DAZU: Zwei Zeilen mit derselben Spaltenueberschrift,
217 * aber verschiedenen Formeln dahinter sind ohne sie nicht nachzurechnen.
218 * Wer die Zeile mit der Knotenpunktformel nachrechnet, sucht sonst die
219 * fehlenden 6 m.
220 */
221 expect(text).toContain('Abschnitt 5.2.2');
222 expect(text).toContain('OHNE Fahrzeuglänge');
223 expect(text).toContain('Eine Einfahrzeit wird nicht abgezogen');
224 });
225 });
226
227 describe('Die beiden Pruefregeln aus 5.21.0 stehen auch auf dem Papier', () => {
228 /*
229 * EIN BEFUND, DEN NUR DER BILDSCHIRM ZEIGT, ist keiner. Die Anordnung geht
230 * als Unterlage aus der Hand; wer sie prueft, hat den Bildschirm nicht. Die
231 * beiden Regeln, die mit 5.21.0 dazugekommen sind, muessen deshalb im
232 * gedruckten Pruefbericht stehen - mit dem Satz, der die Sache erklaert, und
233 * nicht nur mit ihrer Kennung.
234 */
235 it('nennt die Unterlage die Voraussetzung des OePNV-Ansatzes', () => {
236 const engstelle = ausDemAssistenten('einstreifig');
237 const mitBus: Project = {
238 ...engstelle,
239 signalGroups: engstelle.signalGroups.map((g) =>
240 g.name === 'K1'
241 ? { ...g, mode: 'oepnv' as const, vehicleClass: defaultVehicleClass('oepnv') }
242 : g,
243 ),
244 };
245 const text = ausdruck(mitBus);
246 expect(text).toContain('Räumgeschwindigkeit des ÖPNV an der Arbeitsstelle');
247 expect(text, 'die beiden Geschwindigkeiten fehlen').toContain('13,9 m/s');
248 expect(text, 'die Voraussetzung fehlt').toContain('gesondert signalisiert');
249
250 // Und ohne OePNV steht davon nichts in der Unterlage.
251 expect(ausdruck(engstelle)).not.toContain('Räumgeschwindigkeit des ÖPNV an der Arbeitsstelle');
252 });
253
254 it('sagt die Unterlage, wenn sie gar keine Bewertung enthaelt', () => {
255 const anlage = ausDemAssistenten('fussgaengerschutzanlage');
256 const nachHcm: Project = {
257 ...anlage,
258 settings: { ...anlage.settings, serviceLevelScale: 'HCM' },
259 };
260 const text = ausdruck(nachHcm);
261 expect(text).toContain('Keine Bewertung: Fußgängerschutzanlage nach HCM');
262 expect(text, 'der Weg heraus fehlt').toContain('Wählen Sie das Bewertungsverfahren HBS 2015');
263
264 expect(ausdruck(anlage), 'nach HBS steht die Meldung trotzdem da').not.toContain(
265 'Keine Bewertung: Fußgängerschutzanlage nach HCM',
266 );
267 });
268 });
269
270 describe('Der Unterschied ist nachweisbar und kein Geschmack', () => {
271 it('sagt der ueberschriebene Knotenpunkt bei der Engstelle etwas anderes', () => {
272 /*
273 * DIE GEGENPROBE ZU DIESER GANZEN DATEI. Ohne sie waere nicht gezeigt,
274 * dass die Faelle oben ueberhaupt etwas Neues pruefen - sie koennten
275 * ebenso gut am alten Pruefstueck gruen sein, und dann waere die echte
276 * Geometrie ein Aufwand ohne Ertrag.
277 *
278 * Zugleich ist dieser Fall die Wache dagegen, die Faelle oben spaeter
279 * wieder auf die bequemere Vorlage umzustellen.
280 */
281 const vorlage = ausdruck(ueberschrieben('einstreifig'));
282
283 /*
284 * Geprueft wird hier nur die VORLAGE. Dass der richtige Ausdruck diese
285 * Saetze nicht traegt, steht schon in den Faellen darueber; sie hier ein
286 * zweites Mal hinzuschreiben, machte den Nachweis nicht staerker, sondern
287 * nur die Pflege doppelt.
288 */
289 expect(vorlage, 'die Vorlage nennt nicht mehr ihren eigenen Raeumweg').toContain(
290 'Die Engstelle ist mit 22 m erfasst',
291 );
292 expect(vorlage, 'die Vorlage bescheinigt die Anlage nicht mehr als entbehrlich').toContain(
293 'Signalregelung nicht zwingend',
294 );
295 expect(vorlage, 'die Vorlage verlangt die Typklasse D nicht mehr zu prüfen').toContain(
296 'Typklasse D zu prüfen',
297 );
298 expect(vorlage, 'die Vorlage druckt die Furtabsaetze nicht mehr').toContain(
299 'Eine Furt mit Mittelinsel ist über die Insel hinweg zu vermessen',
300 );
301 });
302 });
303
304 describe('Die Planunterlage sagt, welcher Rechenstand sie erzeugt hat', () => {
305 /*
306 * DER BEFUND. Bis 5.25.0 nannte der Ausdruck die Programmfassung nirgends.
307 * Zwischen 5.21.0 und 5.25.0 liegen der Deckel an der zulaessigen
308 * Hoechstgeschwindigkeit (5.22.0) und die Verteilung des Freigabezeitrests
309 * (5.23.0) - zwei Aenderungen, die fuer dasselbe Projekt andere Zahlen in
310 * den Plan schreiben. Zwei Ausdrucke desselben Vorgangs sahen sonst gleich
311 * aus, und welcher Stand welchen erzeugt hatte, war ihnen nicht anzusehen.
312 *
313 * Aufgefallen vor einer Auslieferung. Behoben ist es mit
314 * einer Zeile auf dem Deckblatt; nachtraeglich kennzeichnen laesst sich eine
315 * bereits abgegebene Unterlage nicht.
316 */
317 const text = ausdruck(ausDemAssistenten('kreuzung'));
318
319 it('nennt die Programmfassung auf dem Deckblatt', () => {
320 expect(text, 'die Beschriftung').toContain('Programmfassung');
321 expect(text, 'der Name des Programms').toContain('LSA-Planer Professional');
322 expect(text, 'die Fassung selbst').toContain(FASSUNGSANGABE);
323 });
324
325 it('nennt den Schemastand der Daten daneben', () => {
326 // Er sagt, aus welchem Dateiformat die Angaben stammen; die mitgelieferte
327 // Beispieldatei traegt bewusst einen aelteren.
328 expect(text).toContain('Schemastand der Datei');
329 });
330
331 it('steht die Fassung nur einmal und nicht auf jeder Seite', () => {
332 /*
333 * Die Gegenprobe zur Menge: Die Herkunftsangabe gehoert auf das Deckblatt,
334 * nicht in die Fusszeile jeder Seite - dort steht schon der Bearbeiter,
335 * und die Zeile war bereits einmal zu lang (Befund 5).
336 */
337 const treffer = text.match(/Programmfassung/gu) ?? [];
338 expect(treffer).toHaveLength(1);
339 });
340 });