lsa-planer

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

/ tests export pdf.test.ts

6,6 KB Rohdatei
tests/export/pdf.test.ts — 186 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 { toWinAnsi } from '@/render/pdfSurface';
5 import { buildSignalPlan } from '@/domain/plan/signalPlan';
6 import { validateProject } from '@/domain/validation';
7 import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory';
8 import type { Project } from '@/domain/model/project';
9
10 const FIXED_DATE = new Date('2026-01-01T12:00:00Z');
11
12 function complete(): Project {
13 const base = createStandardIntersectionProject('Hauptstrasse / Bahnhofstrasse', FIXED_DATE);
14 return {
15 ...base,
16 meta: {
17 ...base.meta,
18 projectNumber: 'VP-2026-001',
19 client: 'Stadt Musterhausen',
20 planner: 'O. Willerding',
21 location: 'Hauptstrasse, 12345 Musterstadt',
22 description: 'Bestandsaufnahme mit Umlauten: aeoeue und ss.',
23 },
24 };
25 }
26
27 /** Rohbytes als Latin-1 - fuer Struktur-Angaben wie /Type /Page. */
28 function pdfRaw(bytes: Uint8Array): string {
29 return new TextDecoder('latin1').decode(bytes);
30 }
31
32 /**
33 * Sichtbaren Text aus dem PDF lesen.
34 *
35 * Die Inhaltsstroeme sind komprimiert. Ein Test, der nur die Rohbytes
36 * durchsucht, findet keinen einzigen Buchstaben und geht deshalb immer durch -
37 * er prueft dann nichts. Hier werden die Stroeme entpackt und die
38 * Textoperatoren ausgewertet.
39 */
40 function pdfText(bytes: Uint8Array): string {
41 const roh = pdfRaw(bytes);
42 const teile: string[] = [];
43 const suche = /stream\r?\n/g;
44 let treffer: RegExpExecArray | null;
45
46 while ((treffer = suche.exec(roh)) !== null) {
47 const start = treffer.index + treffer[0].length;
48 const ende = roh.indexOf('endstream', start);
49 if (ende < 0) continue;
50 let inhalt: string;
51 try {
52 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
53 } catch {
54 continue;
55 }
56 for (const stueck of inhalt.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj/g)) {
57 teile.push(stueck[0].slice(1, stueck[0].lastIndexOf(')')));
58 }
59 }
60 return teile.join('\n');
61 }
62
63 describe('PDF-Ausgabe', () => {
64 it('erzeugt ein gueltiges PDF mit mehreren Seiten', () => {
65 const project = complete();
66 const plan = buildSignalPlan(project);
67 const report = validateProject(project, plan, FIXED_DATE);
68
69 const bytes = buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE);
70 expect(bytes.byteLength).toBeGreaterThan(5000);
71
72 const roh = pdfRaw(bytes);
73 expect(roh.startsWith('%PDF-')).toBe(true);
74 expect(roh).toContain('/Type /Page');
75 // Mehrere Seiten: Deckblatt, Signalgruppen, Matrix, Phasen, Plan, ...
76 const pageCount = roh.split('/Type /Page\n').length - 1;
77 expect(pageCount).toBeGreaterThanOrEqual(6);
78
79 // Der sichtbare Text muss lesbar herauszuloesen sein - sonst pruefen die
80 // uebrigen Textzusicherungen nichts.
81 const text = pdfText(bytes);
82 expect(text).toContain('Signalzeitenplan');
83 expect(text).toContain('Hauptstrasse');
84 });
85
86 it('enthaelt keine unbedingte Genehmigungsempfehlung', () => {
87 const project = complete();
88 const plan = buildSignalPlan(project);
89 const report = validateProject(project, plan, FIXED_DATE);
90 const raw = pdfText(buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE));
91
92 // Der Altbestand druckte unabhaengig vom Pruefergebnis, die Anlage werde
93 // zur Genehmigung empfohlen.
94 expect(raw.toLowerCase()).not.toContain('zur genehmigung empfohlen');
95 });
96
97 it('weist die Herkunft der Kennwerte nach', () => {
98 const project = complete();
99 const plan = buildSignalPlan(project);
100 const report = validateProject(project, plan, FIXED_DATE);
101 const raw = pdfText(
102 buildProjectPdf(
103 project,
104 plan,
105 report,
106 {
107 ...DEFAULT_PDF_OPTIONS,
108 fundstellen: { 'rilsa-signalzeiten': 'Abschnitt 3.4, Tabelle 6' },
109 },
110 FIXED_DATE,
111 ),
112 );
113
114 // Der Ausdruck muss die Rechtskette und das Fundstellenverzeichnis enthalten,
115 // damit ein Pruefer die Kennwerte nachvollziehen kann.
116 expect(raw).toContain('Grundlagen der Kennwerte');
117 expect(raw).toContain('Fundstellenverzeichnis');
118 expect(raw).toContain('37 StVO');
119 expect(raw).toContain('RiLSA 2015');
120 expect(raw).toContain('HBS 2015');
121 // Die selbst eingetragene Fundstelle erscheint im Dokument.
122 expect(raw).toContain('Tabelle 6');
123 });
124
125 it('laesst sich auch fuer ein leeres Projekt erzeugen, ohne abzustuerzen', () => {
126 const project = createEmptyProject('Leer', FIXED_DATE);
127 const plan = buildSignalPlan(project);
128 const report = validateProject(project, plan, FIXED_DATE);
129 expect(() =>
130 buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE),
131 ).not.toThrow();
132 });
133
134 it('beruecksichtigt die gewaehlten Abschnitte', () => {
135 const project = complete();
136 const plan = buildSignalPlan(project);
137 const report = validateProject(project, plan, FIXED_DATE);
138
139 const full = buildProjectPdf(project, plan, report, DEFAULT_PDF_OPTIONS, FIXED_DATE);
140 const minimal = buildProjectPdf(
141 project,
142 plan,
143 report,
144 {
145 includeSignalGroups: false,
146 includeIntergreenMatrix: false,
147 includePhases: false,
148 includeCycleComparison: false,
149 includePlan: false,
150 includePerformance: false,
151 includeReport: false,
152 includeCalculationDetails: false,
153 includeSources: false,
154 fundstellen: {},
155 },
156 FIXED_DATE,
157 );
158
159 const fullPages = pdfRaw(full).split('/Type /Page\n').length;
160 const minimalPages = pdfRaw(minimal).split('/Type /Page\n').length;
161 expect(minimalPages).toBeLessThan(fullPages);
162 });
163 });
164
165 describe('Zeichenvorrat der PDF-Standardschriften', () => {
166 it('erhaelt deutsche Umlaute unveraendert', () => {
167 expect(toWinAnsi('Grünzeit, Räumweg, Fußgänger, Übergang')).toBe(
168 'Grünzeit, Räumweg, Fußgänger, Übergang',
169 );
170 });
171
172 it('ersetzt nicht darstellbare Zeichen durch lesbaren Text', () => {
173 // Der Altbestand druckte Haken, Kreuze und Emoji, die mit den
174 // Standardschriften als leere Kaesten erschienen.
175 expect(toWinAnsi('K1 → K2')).toBe('K1 -> K2');
176 expect(toWinAnsi('✓ erfuellt')).toBe('ja erfuellt');
177 expect(toWinAnsi('✗ nicht erfuellt')).toBe('nein nicht erfuellt');
178 expect(toWinAnsi('tz ≥ 5 s')).toBe('tz >= 5 s');
179 expect(toWinAnsi('60–90 s')).toBe('60-90 s');
180 });
181
182 it('entfernt Zeichen, die auch danach nicht darstellbar sind', () => {
183 expect(toWinAnsi('Projekt 🚦 Ampel')).toBe('Projekt Ampel');
184 expect(toWinAnsi('Проект')).toBe('');
185 });
186 });