lsa-planer

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

/ tests export regelbereichFundstelle.test.ts

10,8 KB Rohdatei
tests/export/regelbereichFundstelle.test.ts — 253 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 { ANLAGENART_GRENZEN, RILSA_DEFAULTS } from '@/domain/rilsa/constants';
8 import { toWinAnsi } from '@/render/pdfSurface';
9 import type { Anlagenart, Project } from '@/domain/model/project';
10 import type { RilsaOverrides } from '@/domain/rilsa/settings';
11
12 /*
13 * Offener Rest von Befund 48 (tests/domain/regelbereichBezug.test.ts).
14 *
15 * Befund 48 hat den Bezugswert der Abweichungsliste berichtigt: Bei den beiden
16 * Regelbereichsfeldern der Umlaufzeit misst `resolveRilsaSettings` seither
17 * gegen den Wert, der OHNE Eintrag fuer die Anlagenart gilt (40 bis 70 s an der
18 * Fussgaengerschutzanlage), nicht mehr gegen die Knotenpunktzahl. Das
19 * Fundstellenverzeichnis bildete seine Angabe aber weiter selbst, aus einem
20 * Wertvergleich gegen `settingFields(...).standard`. Gemessen am erzeugten PDF
21 * einer Fussgaengerschutzanlage sagte dieselbe Unterlage damit zweierlei:
22 *
23 * Eintrag 40 s - also genau der Wert, der dort ohnehin gilt:
24 * "Abweichungen von den Regelwerten": keine Zeile.
25 * Fundstellenverzeichnis: "Regelbereich Umlaufzeit von (angesetzt: 40 s,
26 * abweichend vom Regelwert)" neben der Regelwertspalte "60 s".
27 *
28 * Eintrag 60 s - die wirksame Verschiebung des Regelbereichs:
29 * "Abweichungen von den Regelwerten": "Regelbereich Umlaufzeit von | 40 |
30 * 60 | s", und der Pruefbericht daneben "Angesetzt: 60 s, Regelwert: 40 s."
31 * Fundstellenverzeichnis: kein Vermerk, Regelwertspalte "60 s".
32 *
33 * Vor der Behoerde stehen damit zwei Regelwerte fuer denselben Kennwert in
34 * einer Anordnungsunterlage - und einmal eine Begruendungspflicht fuer eine
35 * Eingabe, die an keiner Zahl des Plans etwas aendert.
36 *
37 * DIESER TEST HAELT BEIDE AUFSTELLUNGEN DESSELBEN PDF GEGENEINANDER; genau ihr
38 * Auseinanderlaufen ist der Befund, und ein Test der neuen Stelle fuer sich
39 * allein wuerde ihn nicht fassen.
40 *
41 * GEGEN DEN ALTSTAND schlagen vier der sechs Faelle fehl: die drei zur
42 * Fussgaengerschutzanlage und der zur einstreifigen Verkehrsfuehrung, deren
43 * Regelbereich (60 bis 180 s) dieses Programm ebenfalls selbst setzt. Gruen
44 * bleiben dort die Vorbedingung und der Knotenpunkt.
45 */
46
47 const STICHTAG = new Date('2026-01-01T12:00:00Z');
48
49 const PAGE_MARGIN = 36;
50 const CONTENT_WIDTH = 841.89 - 2 * PAGE_MARGIN;
51 /*
52 * Die Spalten des Fundstellenverzeichnisses, aus den festen Breiten des
53 * Ausdrucks gerechnet: Regelwerk 70, Kennwert (Rest), Regelwert 62,
54 * Sachgebiet 175, Fundstelle 110, Pruefstand 62 - wie in
55 * tests/export/fundstellenRegelwert.test.ts.
56 */
57 const KENNWERT_X = PAGE_MARGIN + 70;
58 const KENNWERT_BREITE = CONTENT_WIDTH - (70 + 62 + 175 + 110 + 62);
59 const REGELWERT_X = KENNWERT_X + KENNWERT_BREITE;
60 const REGELWERT_ENDE = REGELWERT_X + 62;
61 /** Innenabstand der Tabellenzellen dieses Verzeichnisses. */
62 const ZELLENRAND = 2.5;
63
64 interface Stueck {
65 readonly blatt: number;
66 readonly x: number;
67 readonly y: number;
68 readonly text: string;
69 }
70
71 function entpacke(roh: string, von: number): string | null {
72 const anfang = roh.indexOf('stream', von);
73 if (anfang < 0) return null;
74 const start = anfang + (roh.startsWith('stream\r\n', anfang) ? 8 : 7);
75 const ende = roh.indexOf('endstream', start);
76 if (ende < 0) return null;
77 try {
78 return inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
79 } catch {
80 return null;
81 }
82 }
83
84 function textstuecke(bytes: Uint8Array): readonly Stueck[] {
85 const roh = new TextDecoder('latin1').decode(bytes);
86 const raus: Stueck[] = [];
87 let blatt = 0;
88
89 for (const treffer of roh.matchAll(/\/Type \/Page\b[^>]*?\/Contents (\d+) 0 R/g)) {
90 const objekt = roh.indexOf(`\n${treffer[1]!} 0 obj`);
91 const inhalt = objekt < 0 ? null : entpacke(roh, objekt);
92 if (inhalt === null) continue;
93 blatt += 1;
94 let x = 0;
95 let y = 0;
96 let abstand = 0;
97 for (const roheZeile of inhalt.split('\n')) {
98 const zeile = roheZeile.trim();
99 const tl = /^(-?[\d.]+) TL$/.exec(zeile);
100 if (tl) {
101 abstand = Number(tl[1]);
102 continue;
103 }
104 const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile);
105 if (td) {
106 x = Number(td[1]);
107 y = Number(td[2]);
108 continue;
109 }
110 let rest = zeile;
111 while (rest.startsWith('T*')) {
112 y -= abstand;
113 rest = rest.slice(2).trim();
114 }
115 const tj = /^\(((?:[^()\\]|\\.)*)\)\s*Tj$/.exec(rest);
116 if (tj) raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') });
117 }
118 }
119 return raus;
120 }
121
122 function projekt(anlagenart: Anlagenart, rilsa: RilsaOverrides = {}): Project {
123 const basis = createStandardIntersectionProject('Musterkreuzung', STICHTAG);
124 return {
125 ...basis,
126 anlagenart,
127 settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, ...rilsa } },
128 };
129 }
130
131 function unterlage(p: Project): readonly Stueck[] {
132 const plan = buildSignalPlan(p);
133 return textstuecke(
134 buildProjectPdf(p, plan, validateProject(p, plan, STICHTAG), DEFAULT_PDF_OPTIONS, STICHTAG),
135 );
136 }
137
138 /** Die Kennwertzelle des Fundstellenverzeichnisses zu einem Kennwert. */
139 function verzeichnisKennwert(stuecke: readonly Stueck[], label: string): Stueck {
140 const treffer = stuecke.filter(
141 (s) => Math.abs(s.x - (KENNWERT_X + ZELLENRAND)) < 0.5 && s.text.startsWith(toWinAnsi(label)),
142 );
143 expect(treffer.length, `Vorbedingung: Zeile "${label}" im Fundstellenverzeichnis`).toBe(1);
144 return treffer[0]!;
145 }
146
147 /** Die Zelle "Regelwert" des Fundstellenverzeichnisses zu einem Kennwert. */
148 function verzeichnisRegelwert(stuecke: readonly Stueck[], label: string): readonly string[] {
149 const zeile = verzeichnisKennwert(stuecke, label);
150 return stuecke
151 .filter((s) => s.blatt === zeile.blatt && Math.abs(s.y - zeile.y) < 0.5)
152 .filter((s) => s.x >= REGELWERT_X && s.x < REGELWERT_ENDE)
153 .map((s) => s.text);
154 }
155
156 /**
157 * Die Zeile der Aufstellung "Abweichungen von den Regelwerten" zu einem
158 * Kennwert - Kennwert, Regelwert, Angesetzt, Einheit -, oder null, wo die
159 * Aufstellung den Kennwert nicht fuehrt. Ihre erste Spalte beginnt am linken
160 * Satzspiegel und steht damit links der Kennwertspalte des Verzeichnisses.
161 */
162 function aufstellungZeile(stuecke: readonly Stueck[], label: string): readonly string[] | null {
163 const treffer = stuecke.filter((s) => s.x < KENNWERT_X && s.text === toWinAnsi(label));
164 if (treffer.length === 0) return null;
165 expect(treffer.length, `Zeile "${label}" in "Abweichungen von den Regelwerten"`).toBe(1);
166 const zeile = treffer[0]!;
167 return stuecke
168 .filter((s) => s.blatt === zeile.blatt && Math.abs(s.y - zeile.y) < 0.5)
169 .sort((a, b) => a.x - b.x)
170 .map((s) => s.text);
171 }
172
173 const VON = 'Regelbereich Umlaufzeit von';
174 const BIS = 'Regelbereich Umlaufzeit bis';
175
176 describe('Regelwerte beider Aufstellungen desselben PDF', () => {
177 it('haelt an der Fussgaengerschutzanlage die Zahlen der Anlagenart bereit', () => {
178 // Voraussetzung des Aufbaus, wie in regelbereichBezug: Die
179 // Anlagenart fuehrt einen anderen Regelbereich als der Knotenpunkt.
180 expect(ANLAGENART_GRENZEN.fussgaengerschutzanlage.cycleTime.recommendedMin).toBe(40);
181 // Seit 5.27.0 der Richtwert der RiLSA (Abschnitt 2.6) statt der 60 s.
182 expect(RILSA_DEFAULTS.cycleTime.recommendedMin).toBe(30);
183 });
184
185 it('nennt bei einer wirksamen Vorgabe in beiden Aufstellungen denselben Regelwert', () => {
186 const stuecke = unterlage(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 60 }));
187
188 // 60 s verschieben den Regelbereich von 40 bis 70 auf 60 bis 70.
189 expect(aufstellungZeile(stuecke, VON)).toEqual([toWinAnsi(VON), '40', '60', 's']);
190 expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']);
191 expect(verzeichnisKennwert(stuecke, VON).text).toBe(
192 toWinAnsi(`${VON} (angesetzt: 60 s, abweichend vom Regelwert)`),
193 );
194 });
195
196 it('macht aus einer wirkungslosen Vorgabe auch im Verzeichnis keine Abweichung', () => {
197 const stuecke = unterlage(projekt('fussgaengerschutzanlage', { cycleTimeRecommendedMin: 40 }));
198
199 // 40 s gelten dort ohnehin - keine der beiden Aufstellungen darf dafuer
200 // eine Begruendung verlangen.
201 expect(aufstellungZeile(stuecke, VON)).toBeNull();
202 expect(verzeichnisKennwert(stuecke, VON).text).toBe(toWinAnsi(VON));
203 expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']);
204 });
205
206 it('haengt die Spalte "Regelwert" an der Anlagenart und nicht am Eintrag', () => {
207 // Ohne Eintrag dieselbe Zahl wie mit den beiden Eintraegen darueber: Eine
208 // Regelwertspalte, die zwischen 40 und 60 s wechselt, je nachdem ob jemand
209 // etwas eingetragen hat, waere derselbe Widerspruch in einer Zeile.
210 const stuecke = unterlage(projekt('fussgaengerschutzanlage'));
211
212 expect(aufstellungZeile(stuecke, VON)).toBeNull();
213 expect(verzeichnisKennwert(stuecke, VON).text).toBe(toWinAnsi(VON));
214 expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['40 s']);
215 expect(verzeichnisRegelwert(stuecke, BIS)).toEqual(['70 s']);
216 });
217
218 it('misst am Knotenpunkt gegen den Richtwert der RiLSA', () => {
219 // 40 s weichen seit 5.27.0 vom Regelwert 30 s ab - zuvor lag der bei 60 s,
220 // und dieselbe Eingabe war eine Abweichung in die andere Richtung.
221 const stuecke = unterlage(projekt('knotenpunkt', { cycleTimeRecommendedMin: 40 }));
222
223 expect(aufstellungZeile(stuecke, VON)).toEqual([toWinAnsi(VON), '30', '40', 's']);
224 expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['30 s']);
225 expect(verzeichnisKennwert(stuecke, VON).text).toBe(
226 toWinAnsi(`${VON} (angesetzt: 40 s, abweichend vom Regelwert)`),
227 );
228 });
229
230 it('misst die groesste Umlaufzeit weiterhin gegen den Regelwerkswert', () => {
231 /*
232 * Die Fortsetzung der Gegenprobe aus regelbereichBezug im Ausdruck:
233 * Bei der kleinsten und groessten Umlaufzeit bleibt der Knotenpunktwert der
234 * Bezug, weil er dort ein Regelwerkswert ist (30 bis 120 s, nicht nach
235 * Anlagenart gestaffelt) - die beiden Zeilen zur groessten Umlaufzeit sind
236 * deshalb in beiden Staenden gruen. Nur die beiden Regelbereichsfelder
237 * folgen der Anlagenart; bei einstreifiger Verkehrsfuehrung setzt dieses
238 * Programm 60 bis 180 s, und der Altstand druckte dort die 90 s des
239 * Knotenpunkts.
240 */
241 const stuecke = unterlage(projekt('einstreifig', { cycleTimeMax: 250 }));
242
243 expect(aufstellungZeile(stuecke, 'Größte Umlaufzeit')).toEqual([
244 toWinAnsi('Größte Umlaufzeit'),
245 '120',
246 '250',
247 's',
248 ]);
249 expect(verzeichnisRegelwert(stuecke, 'Größte Umlaufzeit')).toEqual(['120 s']);
250 expect(verzeichnisRegelwert(stuecke, VON)).toEqual(['60 s']);
251 expect(verzeichnisRegelwert(stuecke, BIS)).toEqual(['180 s']);
252 });
253 });