lsa-planer

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

/ tests export hoechstfreigabezeit.test.ts

15,3 KB Rohdatei
tests/export/hoechstfreigabezeit.test.ts — 385 Zeilen
1 import { inflateSync } from 'node:zlib';
2 import { describe, expect, it } from 'vitest';
3 import { signalGroupsCsv } from '@/services/export/csv';
4 import { buildProjectPdf, DEFAULT_PDF_OPTIONS } from '@/services/export/pdf';
5 import { buildSignalPlan } from '@/domain/plan/signalPlan';
6 import { validateProject } from '@/domain/validation';
7 import { createStandardIntersectionProject } from '@/domain/model/factory';
8 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
9 import { toWinAnsi } from '@/render/pdfSurface';
10 import type { Project } from '@/domain/model/project';
11 import * as fmt from '@/ui/format';
12
13 /*
14 * Fassung 5.9.0 - die Hoechstfreigabezeit stand in keiner Unterlage.
15 *
16 * Seit den Befunden 36 und 42 wirkt eine je Signalgruppe eingetragene
17 * Hoechstfreigabezeit (`SignalGroup.maxGreenOverride`) in der
18 * Freigabezeitverteilung: `phaseMaxGreen` (signalPlan.ts) nimmt die KLEINSTE
19 * Vorgabe der Signalgruppen einer Phase statt des Regelwerts. Gedruckt wurde
20 * sie nirgends - weder im Ausdruck noch in der Tabellenausgabe. Damit stand in
21 * der Planunterlage eine Phasendauer, die sich aus den gedruckten Groessen
22 * nicht mehr herleiten laesst, und das ist der Zweck der Unterlage.
23 *
24 * DIE SPALTE NENNT DIE VORGABE, NICHT DIE GELTENDE SCHRANKE. Die erste Fassung schrieb
25 * ohne Eintrag den Regelwert und sagte im Docblock zu, die Spalte nenne "die
26 * Schranke, die gilt". Das hielt in drei Lagen nicht: bei einer festen
27 * Freigabezeit der Phase (sie geht vor), bei einer Vorgabe unter der
28 * Mindestfreigabezeit (dann gilt diese, und der Pruefbericht sagt es im selben
29 * Dokument) und bei einer negativen Vorgabe, die das Einlesen durchlaesst.
30 * Massgebend ist ohnehin nichts, was an EINER Signalgruppe steht: Die wirksame
31 * obere Schranke bildet `phaseMaxGreen` je PHASE aus der kleinsten Vorgabe
32 * ihrer Signalgruppen. Die Zelle weist deshalb den Eintrag als Eintrag aus,
33 * "–" bzw. leer heisst "keiner", und der Absatz unter der Tabelle sagt, was
34 * ueber die wirksame Schranke entscheidet.
35 *
36 * MUTATIONSNACHWEIS, nachgemessen gegen den Stand vor dieser Berichtigung
37 * (Spaltenkopf "Höchstfreigabezeit [s]" bzw. "Min./Max.", Regelwert statt
38 * leerer Zelle, kein Absatz unter der Tabelle): 7 von 8 Faellen fallen - die
39 * ersten sieben. Der achte misst den Satzspiegel bei sechzehn Signalgruppen
40 * mit langen Namen; er ist in beiden Staenden gruen und bewacht, dass die
41 * Spalte den Satz nicht sprengt.
42 */
43
44 const STICHTAG = new Date('2026-01-01T12:00:00Z');
45 const VORGABE = 47;
46 /** Feste Freigabezeit einer Phase, unter der Vorgabe der Signalgruppe. */
47 const FEST = 30;
48 /** Vorgabe unter der Mindestfreigabezeit - sie kommt nicht zur Wirkung. */
49 const ZU_KLEIN = 1;
50 const KOPF_MIN = 'Mindestfreigabezeit [s]';
51 const KOPF_MAX = 'Höchstfreigabezeit: Vorgabe der Signalgruppe [s] (leer = keine)';
52 /**
53 * Der Spaltenkopf des Ausdrucks: Mindestfreigabezeit und Vorgabe teilen sich
54 * dort eine Spalte.
55 */
56 const SPALTE_PDF = 'Min./Vorgabe';
57
58 const BLATTBREITE = 841.89;
59 const PAGE_MARGIN = 36;
60
61 /** Knotenpunkt, dessen erste Signalgruppe eine Hoechstfreigabezeit traegt. */
62 function mitVorgabe(vorgabe: number = VORGABE): Project {
63 const basis = createStandardIntersectionProject('Höchstfreigabezeit', STICHTAG);
64 return {
65 ...basis,
66 signalGroups: basis.signalGroups.map((g, index) =>
67 index === 0 ? { ...g, maxGreenOverride: vorgabe } : g,
68 ),
69 };
70 }
71
72 /**
73 * Derselbe Knotenpunkt, dessen erste Phase zusaetzlich eine feste
74 * Freigabezeit traegt - die geht der Vorgabe der Signalgruppen vor
75 * (`phaseMaxGreen`, signalPlan.ts).
76 */
77 function mitFesterFreigabezeit(): Project {
78 const basis = mitVorgabe();
79 return {
80 ...basis,
81 phases: basis.phases.map((p, index) => (index === 0 ? { ...p, manualGreen: FEST } : p)),
82 };
83 }
84
85 // --- Tabellenausgabe --------------------------------------------------------
86
87 function zerlege(zeile: string): string[] {
88 return (zeile.match(/("([^"]|"")*"|[^;]*)(;|$)/g) ?? []).map((feld) =>
89 feld.replace(/;$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'),
90 );
91 }
92
93 function tabelle(project: Project): string[][] {
94 return signalGroupsCsv(project, buildSignalPlan(project))
95 .replace(/^/, '')
96 .split('\r\n')
97 .filter((z) => z.length > 0)
98 .map(zerlege);
99 }
100
101 // --- Ausdruck ---------------------------------------------------------------
102
103 interface Stueck {
104 readonly blatt: number;
105 readonly x: number;
106 readonly y: number;
107 readonly text: string;
108 }
109
110 /** Textstuecke mit ihrer Lage auf dem Blatt - eine Tabellenzeile teilt sich ein y. */
111 function stuecke(bytes: Uint8Array): Stueck[] {
112 const roh = new TextDecoder('latin1').decode(bytes);
113 const raus: Stueck[] = [];
114 const suche = /stream\r?\n/g;
115 let treffer: RegExpExecArray | null;
116 let blatt = 0;
117
118 while ((treffer = suche.exec(roh)) !== null) {
119 const start = treffer.index + treffer[0].length;
120 const ende = roh.indexOf('endstream', start);
121 if (ende < 0) continue;
122 let inhalt: string;
123 try {
124 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
125 } catch {
126 continue;
127 }
128 if (!inhalt.includes('Td') && !inhalt.includes('Tj')) continue;
129 blatt += 1;
130 let x = 0;
131 let y = 0;
132 for (const zeile of inhalt.split('\n')) {
133 const td = /^(-?[\d.]+) (-?[\d.]+) Td$/.exec(zeile.trim());
134 if (td) {
135 x = Number(td[1]);
136 y = Number(td[2]);
137 continue;
138 }
139 const tj = /^\((.*)\)\s*Tj$/.exec(zeile.trim());
140 if (tj && tj[1]!.trim() !== '') {
141 raus.push({ blatt, x, y, text: tj[1]!.replace(/\\([()\\])/g, '$1') });
142 }
143 }
144 }
145 return raus;
146 }
147
148 /**
149 * Der ganze sichtbare Text der Unterlage in einer Zeichenkette.
150 *
151 * Neben `stuecke`, weil beide etwas anderes koennen: `stuecke` haelt die Lage
152 * fest (daran haengt die Zeile der Signalgruppentabelle), erfasst aber nur
153 * Textstuecke mit eigener Setzmarke - die Fliesstextzeilen der Absaetze setzt
154 * jsPDF mit "T*" fort und fielen dort durch.
155 */
156 function volltext(bytes: Uint8Array): string {
157 const roh = new TextDecoder('latin1').decode(bytes);
158 const teile: string[] = [];
159 const suche = /stream\r?\n/g;
160 let treffer: RegExpExecArray | null;
161
162 while ((treffer = suche.exec(roh)) !== null) {
163 const start = treffer.index + treffer[0].length;
164 const ende = roh.indexOf('endstream', start);
165 if (ende < 0) continue;
166 let inhalt: string;
167 try {
168 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
169 } catch {
170 continue;
171 }
172 for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
173 teile.push(t[1]!.replace(/\\([()\\])/g, '$1'));
174 }
175 }
176 return teile.join(' ').replace(/\s+/g, ' ');
177 }
178
179 function unterlage(project: Project): Uint8Array {
180 const plan = buildSignalPlan(project);
181 return buildProjectPdf(
182 project,
183 plan,
184 validateProject(project, plan, STICHTAG),
185 DEFAULT_PDF_OPTIONS,
186 STICHTAG,
187 );
188 }
189
190 function ausdruck(project: Project): Stueck[] {
191 return stuecke(unterlage(project));
192 }
193
194 /**
195 * Die Zeile der Signalgruppentabelle zu einer Gruppe, Zelle fuer Zelle von
196 * links nach rechts.
197 *
198 * Erkannt wird sie an der Zelle "V zul", die es nur in dieser Tabelle gibt;
199 * Zellen mit mehreren Zeilen stehen mit ihrer ERSTEN Zeile auf derselben
200 * Grundlinie wie der Name.
201 */
202 function signalgruppenzeile(alle: readonly Stueck[], name: string): string[] {
203 const anker = alle.find(
204 (s) =>
205 s.text === name &&
206 alle.some((n) => n.blatt === s.blatt && n.y === s.y && n.text.endsWith('km/h')),
207 );
208 expect(anker, `Zeile der Gruppe "${name}" steht in der Signalgruppentabelle`).toBeDefined();
209 return alle
210 .filter((s) => s.blatt === anker!.blatt && s.y === anker!.y)
211 .sort((a, b) => a.x - b.x)
212 .map((s) => s.text);
213 }
214
215 describe('Die Höchstfreigabezeit steht in Ausdruck und Tabellenausgabe', () => {
216 it('führt in der Tabellenausgabe eine Spalte neben der Mindestfreigabezeit', () => {
217 const zeilen = tabelle(mitVorgabe());
218 const kopf = zeilen[0]!;
219 const min = kopf.indexOf(KOPF_MIN);
220
221 expect(min, 'Vorbedingung: die Mindestfreigabezeit steht in der Tabelle').toBeGreaterThan(0);
222 expect(kopf[min + 1]).toBe(KOPF_MAX);
223 });
224
225 it('schreibt in der Tabellenausgabe den Eintrag der Gruppe, sonst nichts', () => {
226 const projekt = mitVorgabe();
227 const plan = buildSignalPlan(projekt);
228 const zeilen = tabelle(projekt);
229 const spalte = zeilen[0]!.indexOf(KOPF_MAX);
230
231 expect(plan.defaults.maxGreen, 'Vorbedingung: es gibt einen Regelwert').toBeGreaterThan(0);
232 for (const gruppe of projekt.signalGroups) {
233 const zeile = zeilen.slice(1).find((z) => z[0] === gruppe.name)!;
234 // Ohne Eintrag bleibt die Zelle LEER und nicht der Regelwert: Der gilt
235 // nur, solange keine Signalgruppe DERSELBEN PHASE eine kleinere Vorgabe
236 // traegt - eine Zelle je Gruppe kann das nicht sagen.
237 expect(zeile[spalte], gruppe.name).toBe(
238 gruppe.maxGreenOverride === null ? '' : fmt.numShort(gruppe.maxGreenOverride),
239 );
240 }
241 });
242
243 it('nennt sie im Ausdruck an der Stelle der Mindestfreigabezeit', () => {
244 const alle = ausdruck(mitVorgabe());
245
246 /*
247 * EINE SPALTE FUER BEIDE ANGABEN: Die Signalgruppentabelle des Ausdrucks
248 * ist quer voll (siehe die Begruendung am Spaltenkopf in pdf.ts). Eine
249 * vierzehnte Spalte nimmt den Nachbarn so viel Breite, dass
250 * Signalgruppennamen und Fahrzeugarten umbrechen, die Zeilen hoeher werden
251 * und die Tabelle frueher auf das naechste Blatt umbricht - nachgestellt
252 * mit einer eigenen Spalte "Max. Freigabe" hinter "Min. Freigabe": Dabei
253 * faellt tests/export/pdfSignalgruppenNummer.test.ts mit 3 Faellen (die
254 * Ausgabe zeigt NaN statt der Nummern 6 und 10). tests/export/
255 * pdfBeschriftungen.test.ts und tests/export/pdfSatzspiegel.test.ts melden
256 * den Fall NICHT - sie bleiben dabei gruen; der letzte Fall dieser Datei
257 * misst deshalb selbst.
258 */
259 expect(alle.map((s) => s.text)).toContain(SPALTE_PDF);
260 });
261
262 it('druckt den Eintrag, sonst einen Gedankenstrich', () => {
263 const projekt = mitVorgabe();
264 const plan = buildSignalPlan(projekt);
265 const alle = ausdruck(projekt);
266
267 for (const gruppe of projekt.signalGroups) {
268 const zellen = signalgruppenzeile(alle, gruppe.name);
269 const planned = plan.groups.find((g) => g.groupId === gruppe.id)!;
270 // Nr., SG, Verkehrsart, Fahrbeziehung, V zul, Fahrstr., Fahrzeugart,
271 // Furt, Rot-Gelb, Gelb, Min./Vorgabe, ...
272 expect(zellen[10], `${gruppe.name}: ${SPALTE_PDF}`).toBe(
273 `${fmt.seconds(planned.times.minGreen)} / ` +
274 (gruppe.maxGreenOverride === null
275 ? toWinAnsi('–')
276 : fmt.seconds(gruppe.maxGreenOverride)),
277 );
278 }
279 });
280
281 it('sagt unter der Tabelle, was über die wirksame obere Schranke entscheidet', () => {
282 const projekt = mitVorgabe();
283 const plan = buildSignalPlan(projekt);
284 const text = volltext(unterlage(projekt));
285
286 // Der Absatz nennt den Regelwert dieses Plans - in der Zelle steht er
287 // nicht mehr, und ohne ihn liesse sich die Phasendauer einer Gruppe ohne
288 // Eintrag nicht herleiten.
289 expect(text).toContain(toWinAnsi(`Regelwert von ${fmt.seconds(plan.defaults.maxGreen)}`));
290 // Und er sagt, dass die rechte Zahl der Zelle die Eingangsgroesse ist ...
291 expect(text).toContain(toWinAnsi('nicht die im Plan wirksame obere Schranke'));
292 // ... und wer ueber die wirksame entscheidet: die Phase.
293 expect(text).toContain(toWinAnsi('kleinste Vorgabe ihrer Signalgruppen'));
294 });
295
296 it('widerspricht dem Prüfbericht nicht, wenn die Vorgabe unter der Mindestfreigabezeit liegt', () => {
297 /*
298 * Die Lage, an der die erste Fassung scheiterte: Die Tabelle wies 1 s als
299 * geltende Hoechstfreigabezeit aus - eine Obergrenze unter der Untergrenze
300 * -, waehrend der Pruefbericht desselben Dokuments sagte, massgebend bleibe
301 * die Mindestfreigabezeit. Jetzt weist die Spalte den Eintrag als Eintrag
302 * aus, und der Absatz nennt den Pruefbericht als die Stelle, die den Fall
303 * meldet.
304 */
305 const projekt = mitVorgabe(ZU_KLEIN);
306 const plan = buildSignalPlan(projekt);
307 const gruppe = projekt.signalGroups[0]!;
308 const bytes = unterlage(projekt);
309 const alle = stuecke(bytes);
310 const text = volltext(bytes);
311
312 expect(
313 plan.groups.find((g) => g.groupId === gruppe.id)!.times.minGreen,
314 'Vorbedingung: die Mindestfreigabezeit liegt über der Vorgabe',
315 ).toBeGreaterThan(ZU_KLEIN);
316 // Der Pruefbericht steht in derselben Datei und sagt, was gilt.
317 expect(text).toContain(toWinAnsi('Maßgebend bleibt die Mindestfreigabezeit'));
318 // Die Zelle behauptet daneben keine geltende Schranke mehr, sondern nennt
319 // den Eintrag - und der Absatz sagt, wo der Fall gemeldet ist. Der ganze
320 // Satzteil, nicht nur "meldet der Prüfbericht": Den Wortlaut fuehrt auch
321 // der Absatz zum engen Innenradius, und ein Fall, der ihn dort findet,
322 // bewacht nichts.
323 expect(signalgruppenzeile(alle, gruppe.name)[10]).toBe(
324 `${fmt.seconds(plan.groups.find((g) => g.groupId === gruppe.id)!.times.minGreen)} / ` +
325 fmt.seconds(ZU_KLEIN),
326 );
327 expect(text).toContain(
328 toWinAnsi(
329 'die in ihrer Phase unter der Mindestfreigabezeit oder unter einer festen Freigabezeit ' +
330 'liegt, meldet der Prüfbericht',
331 ),
332 );
333 });
334
335 it('lässt bei fester Freigabezeit der Phase die feste Zeit gelten', () => {
336 /*
337 * Zweite Lage der ersten Fassung: Die Phase traegt eine feste Freigabezeit
338 * von 30 s, die Gruppe eine Vorgabe von 47 s. Gedruckt wurde "5 s / 47 s"
339 * als geltende Schranke, obwohl 30 s gelten. Die Vorgabe steht weiter da -
340 * sie ist eingetragen -, und der Absatz sagt, dass die feste Freigabezeit
341 * ihr vorgeht; die Phasentabelle nennt die 30 s.
342 */
343 const projekt = mitFesterFreigabezeit();
344 const plan = buildSignalPlan(projekt);
345 const bytes = unterlage(projekt);
346 const alle = stuecke(bytes);
347 const text = volltext(bytes);
348
349 expect(plan.phases[0]!.duration, 'Vorbedingung: die feste Freigabezeit gilt').toBe(FEST);
350 expect(text).toContain(toWinAnsi('feste Freigabezeit der Phase'));
351 expect(signalgruppenzeile(alle, projekt.signalGroups[0]!.name)[10]).toContain(
352 fmt.seconds(VORGABE),
353 );
354 });
355
356 it('sprengt bei sechzehn Signalgruppen mit langen Namen den Satzspiegel nicht', () => {
357 /*
358 * Der Ausdruck hat feste Spaltenbreiten und Umbruchregeln; eine
359 * zusaetzliche Spalte kann den Satz sprengen. Gemessen wird an demselben
360 * Pruefstueck wie in tests/export/pdfSatzspiegel.test.ts: sechzehn
361 * Signalgruppen mit langen Namen.
362 */
363 const basis = projektAusAngaben({
364 ...ASSISTENT_VORGABE,
365 form: 'kreuzung',
366 fussgaenger: true,
367 rad: true,
368 linksabbieger: true,
369 });
370 const gruppen = Array.from({ length: 16 }, (_, i) => ({
371 ...basis.signalGroups[i % basis.signalGroups.length]!,
372 id: `sg-${i}`,
373 name: `Signalgruppe ${i + 1} Nordwest Geradeaus und Rechtsabbieger`,
374 maxGreenOverride: i === 0 ? VORGABE : null,
375 }));
376 const alle = ausdruck({ ...basis, signalGroups: gruppen, conflicts: [] });
377
378 expect(alle.length, 'Vorbedingung: die Unterlage trägt Text').toBeGreaterThan(100);
379 const raus = alle.filter((s) => s.x < PAGE_MARGIN || s.x > BLATTBREITE - PAGE_MARGIN);
380 expect(
381 raus.length,
382 raus.length ? `zuerst x=${raus[0]!.x}: "${raus[0]!.text.slice(0, 40)}"` : '',
383 ).toBe(0);
384 });
385 });