lsa-planer

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

/ tests export pdfBeschriftungen.test.ts

7,2 KB Rohdatei
tests/export/pdfBeschriftungen.test.ts — 190 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 { buildSignalPlan } from '@/domain/plan/signalPlan';
5 import { validateProject } from '@/domain/validation';
6 import { createStandardIntersectionProject } from '@/domain/model/factory';
7 import { MOVEMENT_LABELS, fahrzeugartBeschriftung } from '@/domain/rilsa/constants';
8 import { CATEGORY_LABELS } from '@/domain/validation/types';
9 import type { Project } from '@/domain/model/project';
10
11 /*
12 * Der Ausdruck schrieb die internen Schluessel in die Spalten, in denen am
13 * Bildschirm die Beschriftungen stehen. Der Anwender hat die Werte aus einer
14 * Auswahlliste gewaehlt und fand sie in der Unterlage anders wieder:
15 * "rechts" statt "rechts abbiegend", "keine" statt "ohne Laengenzuschlag" -
16 * und im Pruefbericht "leistungsfaehigkeit", zugleich ein Verstoss gegen die
17 * Umlautregel.
18 */
19
20 const STICHTAG = new Date('2026-01-01T12:00:00Z');
21
22 /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */
23 function pdfText(bytes: Uint8Array): string {
24 const roh = new TextDecoder('latin1').decode(bytes);
25 const teile: string[] = [];
26 const suche = /stream\r?\n/g;
27 let treffer: RegExpExecArray | null;
28
29 while ((treffer = suche.exec(roh)) !== null) {
30 const start = treffer.index + treffer[0].length;
31 const ende = roh.indexOf('endstream', start);
32 if (ende < 0) continue;
33 let inhalt: string;
34 try {
35 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
36 } catch {
37 continue;
38 }
39 for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
40 teile.push(t[1]!.replace(/\\([()\\])/g, '$1'));
41 }
42 }
43 return teile.join('\n');
44 }
45
46 function ausdruck(project: Project): string {
47 const plan = buildSignalPlan(project);
48 return pdfText(
49 buildProjectPdf(project, plan, validateProject(project, plan, STICHTAG), {
50 ...DEFAULT_PDF_OPTIONS,
51 }),
52 );
53 }
54
55 function vollstaendig(): Project {
56 const base = createStandardIntersectionProject('Beschriftungen', STICHTAG);
57 return {
58 ...base,
59 meta: {
60 ...base.meta,
61 projectNumber: 'VP-2026-001',
62 client: 'Stadt Musterhausen',
63 planner: 'O. Willerding',
64 location: 'Hauptstraße / Bahnhofstraße',
65 },
66 };
67 }
68
69 describe('Ausdruck benennt wie der Bildschirm', () => {
70 it('druckt Fahrbeziehung und Fahrzeugart als Beschriftung', () => {
71 const text = ausdruck(vollstaendig());
72
73 // Der Regelknotenpunkt fuehrt geradeaus fahrende Kfz und querende
74 // Fussgaenger; letztere ohne Laengenzuschlag.
75 expect(text).toContain(MOVEMENT_LABELS.querung);
76 expect(text).toContain(fahrzeugartBeschriftung('pkw'));
77 expect(text).toContain(fahrzeugartBeschriftung('keine'));
78
79 // Der WinAnsi-Text enthaelt die Schluessel nicht mehr als eigenstaendige
80 // Zellen. "querung" kommt in keiner Beschriftung vor.
81 expect(text).not.toMatch(/^querung$/m);
82 expect(text).not.toMatch(/^keine$/m);
83 expect(text).not.toMatch(/^pkw$/m);
84 });
85
86 it('druckt den Bereich des Pruefberichts als Beschriftung', () => {
87 const projekt = vollstaendig();
88 const bericht = validateProject(projekt, buildSignalPlan(projekt), STICHTAG);
89 expect(bericht.findings.length).toBeGreaterThan(0);
90
91 const text = ausdruck(projekt);
92 for (const befund of new Set(bericht.findings.map((f) => f.category))) {
93 expect(text, befund).toContain(CATEGORY_LABELS[befund]);
94 }
95 // Umschrift im Anwendertext: der Schluessel traegt ein ae, die
96 // Beschriftung ein ae-Umlaut.
97 expect(text).not.toMatch(/^leistungsfaehigkeit$/m);
98 expect(text).not.toMatch(/^zwischenzeiten$/m);
99 });
100
101 it('druckt den Laengenzuschlag, mit dem tatsaechlich gerechnet wird', () => {
102 // Eine Unterlage darf nicht zwei verschiedene Fahrzeuglaengen fuer dieselbe
103 // Signalgruppe nennen. Die Abweichungstabelle im selben Dokument weist die
104 // 22 m ohnehin aus - die Spalte "Fahrzeugart" sagte trotzdem 18,75 m.
105 const roh = vollstaendig();
106 const projekt: Project = {
107 ...roh,
108 signalGroups: roh.signalGroups.map((g, i) =>
109 i === 0 ? { ...g, vehicleClass: 'lastzug' as const } : g,
110 ),
111 settings: { ...roh.settings, rilsa: { ...roh.settings.rilsa, vehicleLengthLastzug: 22 } },
112 };
113 const text = ausdruck(projekt);
114
115 expect(text).toContain('Lastzug (22 m)');
116 expect(text).not.toContain('Lastzug (18,75 m)');
117 });
118
119 it('druckt beim OePNV-Haltefall Beschleunigung und Formel statt einer Scheingeschwindigkeit', () => {
120 /*
121 * Befund A2 - Grundsatz: Was gedruckt wird, muss sich aus den gedruckten
122 * Groessen nachrechnen lassen. Beim Anfahransatz (RiLSA Fall 4) gibt es
123 * keine konstante Raeumgeschwindigkeit; die Spalte vr nennt die
124 * Anfahrbeschleunigung, die Fussnote die Formel, und die gedruckte
125 * Raeumzeit ist genau deren Ergebnis.
126 */
127 const roh = vollstaendig();
128 const bahn = {
129 ...roh.signalGroups[0]!,
130 id: 'sg-bahn',
131 name: 'B1',
132 mode: 'oepnv' as const,
133 vehicleClass: 'strassenbahn' as const,
134 vZul: 50,
135 };
136 const projekt: Project = {
137 ...roh,
138 signalGroups: [...roh.signalGroups, bahn],
139 conflicts: [
140 ...roh.conflicts,
141 {
142 ...roh.conflicts[0]!,
143 id: 'cf-bahn',
144 fromId: 'sg-bahn',
145 toId: roh.signalGroups[1]!.id,
146 clearingDistance: 25,
147 enteringDistance: 8,
148 haltVorKnoten: true,
149 },
150 ],
151 };
152 const plan = buildSignalPlan(projekt);
153 const text = pdfText(
154 buildProjectPdf(projekt, plan, validateProject(projekt, plan, STICHTAG), {
155 ...DEFAULT_PDF_OPTIONS,
156 includeCalculationDetails: true,
157 }),
158 );
159
160 // Fahrzeugart mit echtem Umlaut und Rechenlaenge in der Signalgruppentabelle.
161 expect(text).toContain(fahrzeugartBeschriftung('strassenbahn'));
162 // Spalte vr traegt die Beschleunigung, nicht eine Geschwindigkeit.
163 expect(text).toContain('a = 1,0 m/s²');
164 // Die Fussnote benennt den Anfahransatz samt Formel. Der Fliesstext wird
165 // fuer den Satz umbrochen; fuer den Vergleich werden die Zeilen wieder
166 // zusammengesetzt.
167 const fliesstext = text.replace(/\s+/g, ' ');
168 expect(fliesstext).toContain('Anfahransatz');
169 expect(fliesstext).toContain('tr = Wurzel aus (2 · sr / a)');
170 // Die gedruckte Raeumzeit ist das Ergebnis der benannten Formel:
171 // sr = 25 + 15 = 40 m, a = 1,0 -> Wurzel(80) = 8,94 s.
172 expect(text).toContain('8,94 s');
173 });
174
175 it('fuehrt die Herkunft der Wege weiterhin aus derselben Quelle wie die Tabelle', () => {
176 // Die Zelle entsteht seit dieser Aenderung in einem gemeinsamen Modul.
177 // Bliebe sie im Ausdruck aus, waere die Zusammenlegung misslungen. Sie
178 // steht in der Tabelle mit dem Rechenweg, die eigens zuzuschalten ist.
179 const projekt = vollstaendig();
180 const plan = buildSignalPlan(projekt);
181 const text = pdfText(
182 buildProjectPdf(projekt, plan, validateProject(projekt, plan, STICHTAG), {
183 ...DEFAULT_PDF_OPTIONS,
184 includeCalculationDetails: true,
185 }),
186 );
187 expect(text).toContain('Herkunft der Wege');
188 expect(text).toContain('Herkunft nicht nachgewiesen');
189 });
190 });