lsa-planer

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

/ tests export ergebniskasten.test.ts

8,9 KB Rohdatei
tests/export/ergebniskasten.test.ts — 239 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 type { Project } from '@/domain/model/project';
8
9 /*
10 * BEFUND 6 (Fassung 5.11.0): Der Kasten "Ergebnis der Pruefung" wurde ohne
11 * Umbruchpruefung an context.cursor gesetzt.
12 *
13 * Was der Anwender sonst erlebt: Er traegt in der Ansicht "Projekt" eine lange
14 * Projektbezeichnung ein (das Feld hat keine Laengengrenze; auf dem Deckblatt
15 * wird sie bewusst umbrochen statt gekuerzt). Die beiden Deckblatttabellen
16 * ruecken damit nach unten, und der 52 pt hohe Ergebniskasten faellt in den
17 * Fusssteg - erst die farbige Flaeche, dann der Vorbehaltssatz "Die
18 * rechnerische Pruefung ersetzt nicht ... keine verkehrsbehoerdliche Anordnung
19 * nach Paragraf 45 StVO", und schliesslich fehlt er im Ausdruck ganz.
20 *
21 * Der Kasten traegt zugleich die einzige Stelle des Dokuments, an der
22 * report.errorCount steht. Deckblatttitel (Befund 43) und Bemerkung
23 * (Befund 18) sind fuer genau diesen Fall gemessen gesetzt worden, der Block
24 * dazwischen nicht.
25 */
26
27 const STICHTAG = new Date('2026-01-01T12:00:00Z');
28
29 const PAGE_MARGIN = 36;
30 const FOOTER_HEIGHT = 26;
31 /** Untere Kante des Satzspiegels, von der Blattunterkante aus gemessen. */
32 const SATZSPIEGEL_UNTEN = PAGE_MARGIN + FOOTER_HEIGHT;
33 /** Hoehe des Ergebniskastens - dieselbe Zahl wie in pdf.ts. */
34 const KASTENHOEHE = 52;
35 /** Die Texte des Kastens beginnen 10 pt rechts des Satzspiegelrands. */
36 const KASTEN_X = PAGE_MARGIN + 10;
37 /** Breite des Satzspiegels auf A4 quer - so breit ist die Flaeche des Kastens. */
38 const BREITE_SATZSPIEGEL = 841.89 - 2 * PAGE_MARGIN;
39 /** Abstand zwischen der Unterkante des Kastens und dem, was folgt. */
40 const KASTEN_NACHLAUF = 16;
41
42 const BAUSTEIN = 'Knotenpunkt Hauptstraße / Bahnhofstraße Ausbaustufe ';
43
44 /** Eine Projektbezeichnung der gewuenschten Laenge - das Feld ist unbegrenzt. */
45 function bezeichnung(laenge: number): string {
46 let text = '';
47 while (text.length < laenge) text += BAUSTEIN;
48 return text.slice(0, laenge).trim();
49 }
50
51 interface Stueck {
52 readonly blatt: number;
53 readonly x: number;
54 readonly y: number;
55 readonly text: string;
56 }
57
58 interface Flaeche {
59 readonly blatt: number;
60 /** Untere Kante, von der Blattunterkante aus - jsPDF setzt die Hoehe negativ. */
61 readonly unterkante: number;
62 readonly breite: number;
63 readonly hoehe: number;
64 }
65
66 function entpacke(roh: string, von: number): string | null {
67 const anfang = roh.indexOf('stream', von);
68 if (anfang < 0) return null;
69 const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7);
70 const ende = roh.indexOf('endstream', start);
71 if (ende < 0) return null;
72 try {
73 return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
74 } catch {
75 return null;
76 }
77 }
78
79 function seiteninhalte(bytes: Uint8Array): readonly string[] {
80 const roh = new TextDecoder('latin1').decode(bytes);
81 const raus: string[] = [];
82 for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) {
83 const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`);
84 const inhalt = objekt < 0 ? null : entpacke(roh, objekt);
85 if (inhalt !== null) raus.push(inhalt);
86 }
87 return raus;
88 }
89
90 function textstuecke(bytes: Uint8Array): readonly Stueck[] {
91 const raus: Stueck[] = [];
92 let blatt = 0;
93 for (const inhalt of seiteninhalte(bytes)) {
94 blatt += 1;
95 let x = 0;
96 let y = 0;
97 let abstand = 0;
98 for (const roheZeile of inhalt.split('\n')) {
99 const zeile = roheZeile.trim();
100 const tl = /^(-?[\d.]+) TL$/.exec(zeile);
101 if (tl) {
102 abstand = Number(tl[1]);
103 continue;
104 }
105 const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile);
106 if (td) {
107 x = Number(td[1]);
108 y = Number(td[2]);
109 continue;
110 }
111 let rest = zeile;
112 while (rest.startsWith('T*')) {
113 y -= abstand;
114 rest = rest.slice(2).trim();
115 }
116 const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest);
117 if (tj) raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') });
118 }
119 }
120 return raus;
121 }
122
123 /** Die gefuellten Rechtecke der Datei - jsPDF setzt sie als "x y b h re". */
124 function flaechen(bytes: Uint8Array): readonly Flaeche[] {
125 const raus: Flaeche[] = [];
126 let blatt = 0;
127 for (const inhalt of seiteninhalte(bytes)) {
128 blatt += 1;
129 for (const treffer of inhalt.matchAll(/(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) re\b/g)) {
130 raus.push({
131 blatt,
132 unterkante: Number(treffer[2]) + Number(treffer[4]),
133 breite: Number(treffer[3]),
134 hoehe: Math.abs(Number(treffer[4])),
135 });
136 }
137 }
138 return raus;
139 }
140
141 function unterlage(name: string, bemerkung = ''): Uint8Array {
142 const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG);
143 const projekt: Project = {
144 ...basis,
145 meta: { ...basis.meta, name, description: bemerkung },
146 };
147 const plan = buildSignalPlan(projekt);
148 return buildProjectPdf(
149 projekt,
150 plan,
151 validateProject(projekt, plan, STICHTAG),
152 DEFAULT_PDF_OPTIONS,
153 STICHTAG,
154 );
155 }
156
157 /** Die drei Texte des Ergebniskastens - sie stehen als einzige bei x = 46. */
158 function kastentexte(bytes: Uint8Array): readonly Stueck[] {
159 return textstuecke(bytes).filter((s) => Math.abs(s.x - KASTEN_X) < 0.01);
160 }
161
162 describe('Befund 6 - Ergebniskasten des Deckblatts', () => {
163 for (const laenge of [400, 485, 809]) {
164 it(`haelt den Ergebniskasten bei ${laenge} Zeichen Bezeichnung im Satzspiegel`, () => {
165 const bytes = unterlage(bezeichnung(laenge));
166
167 const texte = kastentexte(bytes);
168 expect(
169 texte.map((s) => s.text),
170 'Vorbedingung: Ueberschrift, Pruefergebnis und Vorbehalt stehen im Dokument',
171 ).toHaveLength(3);
172 expect(texte[0]!.text).toBe('Ergebnis der Prüfung');
173 expect(texte[2]!.text).toContain('Paragraf 45 StVO');
174
175 for (const s of texte) {
176 expect(
177 s.y,
178 `"${s.text}" steht auf Blatt ${s.blatt} bei y ${s.y.toFixed(2)}`,
179 ).toBeGreaterThanOrEqual(SATZSPIEGEL_UNTEN);
180 }
181
182 const kasten = flaechen(bytes).filter((f) => Math.abs(f.hoehe - KASTENHOEHE) < 0.01);
183 expect(kasten, 'Vorbedingung: die farbige Flaeche des Kastens').toHaveLength(1);
184 expect(
185 kasten[0]!.unterkante,
186 `Unterkante der farbigen Flaeche bei ${kasten[0]!.unterkante.toFixed(2)}`,
187 ).toBeGreaterThanOrEqual(SATZSPIEGEL_UNTEN);
188 });
189 }
190
191 it('laesst den Kasten bei kurzer Bezeichnung auf dem Deckblatt stehen', () => {
192 const texte = kastentexte(unterlage('Musterkreuzung'));
193 expect(texte).toHaveLength(3);
194 for (const s of texte) expect(s.blatt, `"${s.text}"`).toBe(1);
195 });
196
197 /*
198 * NACHTRAG ZU BEFUND 6: Die Kastenhoehe wird an drei Stellen gebraucht -
199 * zum Reservieren des Platzes, zum Zeichnen der Flaeche und zum
200 * Weiterruecken auf den Bemerkungsblock darunter. Die dritte stand als feste
201 * 68 (= 52 + 16) im Quelltext: Waechst die Kastenhoehe, wandern Reserve und
202 * Flaeche mit, die Ueberschrift "Bemerkungen" aber nicht - sie rutscht in den
203 * Kasten hinein.
204 *
205 * Gemessen wird deshalb nicht die Zahl, sondern der ABSTAND: Die Ueberschrift
206 * steht 16 pt unter der Unterkante der farbigen Flaeche, welche Hoehe diese
207 * auch hat. Gegen eine geaenderte Kastenhoehe ist der Fall rot, solange die
208 * dritte Stelle nicht mitrechnet.
209 */
210 it('setzt die Ueberschrift "Bemerkungen" 16 pt unter die Unterkante des Kastens', () => {
211 // Mit langer Bezeichnung: Dann rutscht der Kasten geschlossen auf ein
212 // frisches Blatt, und die Bemerkung steht unmittelbar darunter - beide
213 // Groessen sind damit auf demselben Blatt messbar. Bei kurzer Bezeichnung
214 // steht der Kasten am Blattfuss, und die Ueberschrift beginnt ohnehin ein
215 // neues Blatt.
216 const bytes = unterlage(
217 bezeichnung(485),
218 'Abstimmung mit der Straßenverkehrsbehörde steht aus.',
219 );
220
221 const ueberschrift = textstuecke(bytes).filter((s) => s.text === 'Bemerkungen');
222 expect(ueberschrift, 'Vorbedingung: die Bemerkung wird gesetzt').toHaveLength(1);
223
224 // Der Kasten ist das einzige gefuellte Rechteck ueber die volle Breite des
225 // Satzspiegels; die Tabellenzellen darueber sind schmaler.
226 const kasten = flaechen(bytes).filter(
227 (f) => f.blatt === ueberschrift[0]!.blatt && Math.abs(f.breite - BREITE_SATZSPIEGEL) < 0.01,
228 );
229 expect(kasten, 'Vorbedingung: Kasten und Ueberschrift stehen auf demselben Blatt').toHaveLength(
230 1,
231 );
232
233 expect(
234 Number((kasten[0]!.unterkante - ueberschrift[0]!.y).toFixed(2)),
235 `Kastenunterkante ${kasten[0]!.unterkante.toFixed(2)}, Überschrift bei ` +
236 `${ueberschrift[0]!.y.toFixed(2)} - der Abstand haengt an der Kastenhoehe`,
237 ).toBe(KASTEN_NACHLAUF);
238 });
239 });