lsa-planer

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

/ tests export pdfErsatzbelege.test.ts

23,3 KB Rohdatei
tests/export/pdfErsatzbelege.test.ts — 502 Zeilen
1 import { inflateSync } from 'node:zlib';
2 import { describe, expect, it } from 'vitest';
3 import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf';
4 import { intergreenDetailCsv, signalGroupsCsv } from '@/services/export/csv';
5 import { buildSignalPlan } from '@/domain/plan/signalPlan';
6 import { validateProject } from '@/domain/validation';
7 import { createStandardIntersectionProject } from '@/domain/model/factory';
8 import {
9 ENGER_RADIUS_LABEL,
10 HBS_STANDARDBEDINGUNGEN,
11 RILSA_DEFAULTS,
12 UEBERFAHRZEIT_ANSATZ_LABELS,
13 } from '@/domain/rilsa/constants';
14 import { PRUEFSTAND_LABELS, QUELLEN } from '@/domain/rilsa/quellen';
15 import { saturationFlow, schwerverkehrsfaktor } from '@/domain/rilsa/capacity';
16 import { toWinAnsi } from '@/render/pdfSurface';
17 import type { Project } from '@/domain/model/project';
18 import * as fmt from '@/ui/format';
19
20 /*
21 * Fassung 5.5.0, Fragen E1, E2 und E6 - was der AUSDRUCK und die
22 * TABELLENAUSGABE davon sagen muessen.
23 *
24 * E6 (Rechenaenderung): Die Saettigungsverkehrsstaerke wurde mit einem festen
25 * Pkw-Gleichwert von 2,0 gemindert. Der Ausdruck nannte diesen Wert unter der
26 * Ueberschrift "Bewertung nach HBS 2015" - er ist nachweislich der Wert des
27 * HCM 2010 (BASt V 400, S. 68). Gerechnet wird jetzt mit dem Anpassungsfaktor
28 * fSV des HBS; die Unterlage muss die Kette fSV -> tB -> qS nennen, sonst
29 * laesst sich die gedruckte Saettigungsverkehrsstaerke aus dem Regelwert je
30 * Fahrstreifen nicht herleiten.
31 *
32 * E2 (waehlbarer Ansatz): Die Ueberfahrzeit des Kraftfahrzeugverkehrs haengt in
33 * der Vorgabe an der Gelbzeit. Wer den Ansatz "feste Ueberfahrzeit" waehlt,
34 * VERKUERZT jede Zwischenzeit eines Kfz-Raeumstroms - beim Abbiegen um bis zu
35 * 3 s. Eine Verkuerzung, die in der Unterlage nicht als Abweichung erscheint,
36 * ist vor der Behoerde nicht ausgewiesen.
37 *
38 * E1 (waehlbares Merkmal): Der enge Innenradius senkt die Raeumgeschwindigkeit
39 * einer einzelnen Beziehung auf 5,0 m/s. Er ist in der RiLSA 2015 nicht
40 * nachweisbar; ohne Kennzeichnung stuende in der Rechenwegtabelle eine
41 * Geschwindigkeit, die wie ein Regelwerkswert aussieht.
42 */
43
44 const STICHTAG = new Date('2026-01-01T12:00:00Z');
45
46 /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */
47 function pdfText(bytes: Uint8Array): string {
48 const roh = new TextDecoder('latin1').decode(bytes);
49 const teile: string[] = [];
50 const suche = /stream\r?\n/g;
51 let treffer: RegExpExecArray | null;
52
53 while ((treffer = suche.exec(roh)) !== null) {
54 const start = treffer.index + treffer[0].length;
55 const ende = roh.indexOf('endstream', start);
56 if (ende < 0) continue;
57 let inhalt: string;
58 try {
59 inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1');
60 } catch {
61 continue;
62 }
63 for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) {
64 teile.push(t[1]!.replace(/\\([()\\])/g, '$1'));
65 }
66 }
67 // Der Ausdruck bricht Saetze und Tabellenzellen um; fuer die Suche wieder
68 // zusammenziehen.
69 return teile.join(' ').replace(/\s+/g, ' ');
70 }
71
72 const gedruckt = (text: string): string => toWinAnsi(text).replace(/\s+/g, ' ');
73
74 /**
75 * Ein Knotenpunkt, dessen erster Kfz-Strom ABBIEGT - nur dort wirkt der enge
76 * Innenradius, und nur dort unterscheiden sich die beiden Ueberfahrzeit-
77 * Ansaetze um mehr als eine Sekunde.
78 */
79 function knotenpunkt(): Project {
80 const basis = createStandardIntersectionProject('Ersatzbelege', STICHTAG);
81 return {
82 ...basis,
83 meta: { ...basis.meta, planner: 'O. Willerding' },
84 signalGroups: basis.signalGroups.map((g, index) =>
85 index === 0 ? { ...g, movement: 'links' as const } : g,
86 ),
87 };
88 }
89
90 /** Dasselbe Projekt mit dem Merkmal "enger Innenradius" an der ERSTEN Beziehung. */
91 function mitEngemRadius(basis: Project = knotenpunkt()): Project {
92 return {
93 ...basis,
94 conflicts: basis.conflicts.map((c, index) => (index === 0 ? { ...c, engerRadius: true } : c)),
95 };
96 }
97
98 /** Dasselbe Projekt mit dem Ansatz "feste Ueberfahrzeit". */
99 function mitFesterUeberfahrzeit(basis: Project = knotenpunkt()): Project {
100 return { ...basis, settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' } };
101 }
102
103 /**
104 * Dasselbe Projekt mit sehr kurzen Raeumwegen und fester Ueberfahrzeit - dort
105 * greift die Untergrenze der Ueberfahr- und Raeumzeit (RiLSA 2015,
106 * Abschnitt 2.5.2: tue + tr >= tG + 1 s).
107 */
108 function mitGreifenderUntergrenze(): Project {
109 const basis = mitFesterUeberfahrzeit();
110 return {
111 ...basis,
112 conflicts: basis.conflicts.map((c) => ({ ...c, clearingDistance: 1, enteringDistance: 0 })),
113 };
114 }
115
116 function ausdruck(project: Project, optionen: PdfOptions = DEFAULT_PDF_OPTIONS): string {
117 const plan = buildSignalPlan(project);
118 const bericht = validateProject(project, plan, STICHTAG);
119 return pdfText(buildProjectPdf(project, plan, bericht, optionen, STICHTAG));
120 }
121
122 describe('E2 - Der Ausdruck nennt den gewaehlten Ueberfahrzeit-Ansatz', () => {
123 it('nennt den Regelansatz auf dem Deckblatt und unter der Zwischenzeitenmatrix', () => {
124 const text = ausdruck(knotenpunkt());
125
126 // Deckblatt: die Zeile neben Umlaufzeit und Bemessungsverfahren. Sie steht
127 // dort, weil die Zwischenzeitenmatrix abwaehlbar ist - siehe den zweiten
128 // Test dieses Blocks.
129 expect(text).toContain(gedruckt('Überfahrzeit Kfz (Rechenansatz)'));
130 expect(text).toContain(gedruckt(UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit));
131
132 // Unter der Matrix die Begruendung: dieser Ansatz ist der konservative.
133 expect(text).toContain(
134 gedruckt('Überfahrzeit des Kraftfahrzeugverkehrs: Gerechnet wurde mit dem Ansatz'),
135 );
136 expect(text).toContain(gedruckt('ist damit die konservative Seite'));
137 // Die Gelbzeitstaffel steht dabei - sonst waere "Überfahrzeit = Gelbzeit"
138 // eine Angabe ohne Zahl.
139 expect(text).toContain(gedruckt('3 s bis 50 km/h, 4 s bis 60 km/h, 5 s bis 70 km/h'));
140 });
141
142 it('nennt den Ansatz auch dann, wenn Zwischenzeitenmatrix und Rechenweg abgewaehlt sind', () => {
143 // Der Fall, um den es geht: Ein Ausdruck ohne Zwischenzeitenabschnitte
144 // enthaelt trotzdem Freigabe- und Uebergangszeiten, die auf dem Ansatz
145 // beruhen. Ohne die Deckblattzeile stuende er nirgends.
146 const text = ausdruck(knotenpunkt(), {
147 ...DEFAULT_PDF_OPTIONS,
148 includeIntergreenMatrix: false,
149 includeCalculationDetails: false,
150 });
151 expect(text).toContain(gedruckt('Überfahrzeit Kfz (Rechenansatz)'));
152 expect(text).toContain(gedruckt(UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit));
153 });
154
155 it('sagt beim Ansatz "feste Ueberfahrzeit", dass er verkuerzt und woher die Werte stammen', () => {
156 const text = ausdruck(mitFesterUeberfahrzeit());
157
158 expect(text).toContain(gedruckt(UEBERFAHRZEIT_ANSATZ_LABELS.fest));
159 // Die Richtung - das Entscheidende. Eine Verkuerzung, die nur als Name
160 // eines Ansatzes erscheint, sieht wie eine Einstellung aus.
161 expect(text).toContain(gedruckt('VERKÜRZT die Zwischenzeiten gegenüber dem Regelansatz'));
162 expect(text).toContain(gedruckt('beim Abbiegen um bis zu 3 s je Übergang'));
163 /*
164 * NACHGEZOGEN (5.27.0): Die Herkunft ist die RiLSA und nicht mehr "die
165 * Zwischenzeitentafeln behördlicher Leitfäden … NICHT aus der RiLSA 2015".
166 * Und die Untergrenze ist eingebaut, statt als nicht belegbar ausgewiesen
167 * zu werden.
168 */
169 expect(text).toContain(gedruckt('Regelwerte der RiLSA 2015 (Abschnitt 2.5.2'));
170 expect(text).not.toContain(gedruckt('NICHT aus der RiLSA 2015'));
171 expect(text).toContain(gedruckt('tü + tr mindestens Gelbzeit + 1 s'));
172 expect(text).not.toContain(gedruckt('NICHT eingebaut'));
173 });
174
175 it('fuehrt den Ansatz "fest" als begruendungspflichtige Abweichung, den Regelansatz nicht', () => {
176 const fest = ausdruck(mitFesterUeberfahrzeit());
177 expect(fest).toContain(gedruckt('Abweichungen von den Regelwerten'));
178 expect(fest).toContain(
179 gedruckt('Überfahrzeit des Kraftfahrzeugverkehrs (Rechenansatz, kein Kennwert)'),
180 );
181 // Regelwert und Angesetztes stehen nebeneinander - eine Abweichung ohne
182 // den Wert, von dem sie abweicht, ist keine.
183 expect(fest).toContain(gedruckt(UEBERFAHRZEIT_ANSATZ_LABELS.gelbzeit));
184 // Richtung UND Groessenordnung, beide aus den wirksamen Vorgaben
185 // (Fassung 5.5.0, Befund B5).
186 expect(fest).toContain(gedruckt('VERKÜRZT die Zwischenzeiten'));
187 expect(fest).toContain(gedruckt('beim Abbiegen um bis zu 3 s'));
188
189 // Gegenprobe: Das unveraenderte Projekt hat keine Abweichung und deshalb
190 // auch keinen Abschnitt.
191 expect(ausdruck(knotenpunkt())).not.toContain(gedruckt('Abweichungen von den Regelwerten'));
192 });
193
194 it('druckt die festen Ueberfahrzeiten aus den Vorgaben, nicht ausgeschrieben', () => {
195 // Befund C21 an einer neuen Stelle: Die beiden Werte sind einstellbar. Ein
196 // Ausdruck, der "3 s geradeaus und 2 s abbiegend" behauptet, waehrend die
197 // Rechnung mit anderen Zahlen lief, ist schlimmer als gar keine Angabe.
198 const basis = mitFesterUeberfahrzeit();
199 const projekt: Project = {
200 ...basis,
201 settings: { ...basis.settings, rilsa: { crossingTimeKfzAbbiegend: 4 } },
202 };
203 const text = ausdruck(projekt);
204
205 expect(text).toContain(gedruckt('angesetzt sind 3 s geradeaus und 4 s abbiegend'));
206 expect(text).not.toContain(gedruckt('angesetzt sind 3 s geradeaus und 2 s abbiegend'));
207 // Der geaenderte Kennwert erscheint zusaetzlich als Abweichung mit seinem
208 // Regelwert - und OHNE den Vermerk "ohne Wirkung", denn beim Ansatz "fest"
209 // wirkt er.
210 expect(text).toContain(gedruckt('Überfahrzeit Kfz abbiegend (Ansatz „feste Überfahrzeit")'));
211 expect(text).not.toContain(
212 gedruckt('Überfahrzeit Kfz abbiegend (Ansatz „feste Überfahrzeit") (ohne Wirkung'),
213 );
214 });
215 });
216
217 describe('Der Rechenweg bleibt nachrechenbar, wo die Untergrenze greift', () => {
218 it('nennt die Untergrenze und die Beziehungen, an denen sie angesetzt ist', () => {
219 /*
220 * Wo tue + tr die Untergrenze tG + 1 s unterschreiten, steht in der Spalte
221 * tz nicht tue + tr - te, sondern die angehobene Summe. Ohne die Fussnote
222 * waere die Zeile aus ihren eigenen Spalten nicht nachzurechnen, und
223 * nachrechenbar muss jede gedruckte Zeile sein.
224 */
225 const text = ausdruck(mitGreifenderUntergrenze());
226
227 expect(text).toContain(gedruckt('tü + tr mindestens Gelbzeit + 1 s'));
228 expect(text).toContain(gedruckt('Abschnitt 2.5.2'));
229 expect(text).toContain(gedruckt('Betroffen'));
230
231 // Gegenprobe: Im unveraenderten Projekt greift sie nicht, und die
232 // Fussnote steht dann auch nicht da.
233 expect(ausdruck(knotenpunkt())).not.toContain(gedruckt('tü + tr mindestens Gelbzeit + 1 s'));
234 });
235 });
236
237 describe('E1 - Die Zwischenzeitentabelle kennzeichnet den engen Innenradius', () => {
238 it('fuehrt die Spalte mit der vollen Beschriftung an der gekennzeichneten Beziehung', () => {
239 const text = ausdruck(mitEngemRadius());
240
241 expect(text).toContain(gedruckt('Enger Innenradius'));
242 // Die volle Beschriftung nennt Schwelle, Herkunft und Grenze der Beleglage
243 // - sie steht in der Zelle, nicht nur in einer Fussnote.
244 expect(text).toContain(gedruckt(ENGER_RADIUS_LABEL));
245 // Seit 5.27.0 nennt die Beschriftung die Fundstelle statt der Lücke.
246 expect(ENGER_RADIUS_LABEL).toContain('RiLSA 2015, Abschnitt 2.5.2, Fall 2');
247
248 // Und die Wirkung: der abbiegende Strom raeumt mit dem kleineren Wert.
249 expect(text).toContain(
250 gedruckt(fmt.metersPerSecond(RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius, 1)),
251 );
252 });
253
254 it('erklaert die Spalte mit den Geschwindigkeiten dieses Plans und nennt die Grenze des Merkmals', () => {
255 const text = ausdruck(knotenpunkt());
256
257 expect(text).toContain(
258 gedruckt(
259 `räumt ein abbiegender Kraftfahrzeugstrom mit ` +
260 `${fmt.metersPerSecond(RILSA_DEFAULTS.clearingSpeed.kfzTurningEngerRadius, 1)} statt mit ` +
261 `${fmt.metersPerSecond(RILSA_DEFAULTS.clearingSpeed.kfzTurning, 1)}`,
262 ),
263 );
264 // Was das Programm NICHT prueft, steht dabei.
265 expect(text).toContain(gedruckt('Auch die Schwelle von 10 m prüft dieses Programm nicht nach'));
266 expect(text).toContain(gedruckt('Bei allen übrigen Strömen bleibt das Merkmal ohne Wirkung'));
267 });
268
269 it('fuehrt das Merkmal als Abweichung mit der Zahl der betroffenen Beziehungen', () => {
270 const projekt = mitEngemRadius();
271 const text = ausdruck(projekt);
272
273 expect(text).toContain(
274 gedruckt(
275 `Räumgeschwindigkeit Kfz abbiegend, Merkmal „enger Innenradius" an 1 von ` +
276 `${projekt.conflicts.length} Konfliktbeziehungen`,
277 ),
278 );
279 expect(text).toContain(gedruckt('Die Wahl VERLÄNGERT die Zwischenzeiten.'));
280 // Gegenprobe: ohne gesetztes Merkmal keine Zeile.
281 expect(ausdruck(knotenpunkt())).not.toContain(gedruckt('Merkmal „enger Innenradius" an'));
282 });
283
284 it('nennt die neue Quelle im Fundstellenverzeichnis mit Pruefstand und Beleglage', () => {
285 /*
286 * Dieser Test haengt NICHT an der Ausgabeschicht: Das Verzeichnis entsteht
287 * aus settingFieldsByQuelle, eine neue Quelle mit Vorgabenfeld erscheint
288 * darin von selbst. Er steht trotzdem hier, und zwar als Sicherung des
289 * Wegs: Genau dieser Weg hat schon einmal versagt - die Quelle
290 * 'hbs-qualitaetsstufen' hatte kein Vorgabenfeld und fehlte deshalb ganz
291 * im gedruckten Nachweis.
292 */
293 const quelle = QUELLEN['praxis-enger-radius'];
294 const text = ausdruck(knotenpunkt());
295
296 expect(text).toContain(gedruckt('Räumgeschwindigkeit Kfz abbiegend bei engem Innenradius'));
297 expect(text).toContain(gedruckt(quelle.sachgebiet));
298 expect(text).toContain(gedruckt(PRUEFSTAND_LABELS[quelle.pruefstand]));
299 // Der Hinweis traegt die Fundstelle in die gedruckte Unterlage - bis
300 // 5.26.0 stand dort die Luecke ("In der RiLSA 2015 nicht nachweisbar").
301 expect(text).toContain(gedruckt('Am Original abgeglichen (10.09.2026)'));
302 expect(text).toContain(gedruckt('Radius des Fahrstreifeninnenrands'));
303 expect(text).not.toContain(gedruckt('zwei byteidentische Spiegel derselben Lehrseite'));
304 });
305 });
306
307 describe('E6 - Der Rechenweg der Kapazitaet nennt fSV und den Zeitbedarfswert', () => {
308 it('nennt die Kette fSV, tB und qS statt des Pkw-Aequivalents 2,0', () => {
309 const text = ausdruck(knotenpunkt());
310 const qS = RILSA_DEFAULTS.capacity.saturationFlow;
311
312 // Die falsche Behauptung des Altstands.
313 expect(text).not.toContain(gedruckt('Pkw-Äquivalent 2,0'));
314
315 expect(text).toContain(
316 gedruckt(`tB = fSV · ${fmt.numShort(3600 / qS, 2)} s und qS = 3600 / tB`),
317 );
318 expect(text).toContain(gedruckt('fSV = (qLV + 1,9 · qSV) / qKfz, also fSV = 1 + 0,9 · pSV'));
319 /*
320 * NACHGEFUEHRT (Fassung 5.6.0): Hier stand die Erwartung "Programm erfasst
321 * keine Aufteilung des Schwerverkehrs". Der Satz war richtig, solange das
322 * Datenmodell keine Aufteilung kannte; seit sie erfassbar ist, waere er
323 * eine falsche Angabe in der Planunterlage. Der Ausdruck nennt jetzt
324 * BEIDE Gleichungen und sagt, dass die Datenlage des einzelnen Stroms
325 * entscheidet.
326 */
327 expect(text).not.toContain(gedruckt('erfasst keine Aufteilung des Schwerverkehrs'));
328 expect(text).toContain(
329 gedruckt('fSV = (qLV + 1,75 · qLkw+Bus + 2,5 · qLkwK) / qKfz (Gl. 2-5)'),
330 );
331 expect(text).toContain(gedruckt('entscheidet die Datenlage DES EINZELNEN STROMS'));
332 });
333
334 it('nennt die Standardbedingungen und die Grenze der Anpassungsfaktoren', () => {
335 const text = ausdruck(knotenpunkt());
336
337 expect(text).toContain(
338 gedruckt(
339 `Längsneigung höchstens ${fmt.numShort(HBS_STANDARDBEDINGUNGEN.laengsneigungBis)} %, ` +
340 `Kurvenradius mindestens ${fmt.meters(HBS_STANDARDBEDINGUNGEN.kurvenradiusAb, 0)}, ` +
341 `Fahrstreifenbreite mindestens ${fmt.meters(HBS_STANDARDBEDINGUNGEN.fahrstreifenbreiteAb, 0)}`,
342 ),
343 );
344 /*
345 * UMGEDREHT MIT 5.30.0. Hier stand die Zusage, der Ausdruck nenne die
346 * Anpassungsfaktoren als NICHT abgebildet - und das war bis dahin richtig.
347 * Sie sind es jetzt. Die Fehlerrichtung bleibt trotzdem zu benennen, nur
348 * an anderer Stelle: Nicht das Programm rechnet zu gross, sondern jede
349 * Zufahrt OHNE Eintrag, denn dort gilt die Standardbedingung als Annahme.
350 */
351 expect(text).toContain(gedruckt('seit 5.30.0 abgebildet'));
352 expect(text).toContain(
353 gedruckt('die Sättigungsverkehrsstärke fällt für eine davon abweichende Zufahrt zu groß aus'),
354 );
355 });
356
357 it('druckt fSV je Signalgruppe so, dass sich die Saettigungsverkehrsstaerke nachrechnen laesst', () => {
358 const projekt = knotenpunkt();
359 const plan = buildSignalPlan(projekt);
360 const text = ausdruck(projekt);
361
362 let geprueft = 0;
363 for (const gruppe of plan.groups) {
364 const nachfrage = projekt.demands.find((d) => d.signalGroupId === gruppe.groupId);
365 if (!nachfrage || gruppe.capacity === null) continue;
366 const gruppendaten = projekt.signalGroups.find((g) => g.id === gruppe.groupId)!;
367 const faktor = schwerverkehrsfaktor(nachfrage.heavyVehicleShare);
368
369 // Die gedruckte Zelle: Anteil, Datenlage und Faktor mit seiner Gleichung.
370 // Fuer dieses Projekt ist keine Aufteilung erfasst - also Gl. 2-6
371 // (vor der Fassung 5.6.0 standen hier nur Anteil und Faktor).
372 expect(text, gruppe.name).toContain(
373 gedruckt(
374 `${fmt.numShort(nachfrage.heavyVehicleShare * 100, 1)} % Aufteilung nicht erfasst ` +
375 `fSV = ${fmt.numShort(faktor, 3)} (Gl. 2-6)`,
376 ),
377 );
378 // Und die Gegenprobe an der Zahl, die daneben steht: qS = Grundwert/fSV.
379 const grundwert = saturationFlow(gruppendaten.movement, gruppendaten.lanes, plan.defaults);
380 expect(gruppe.capacity.saturationFlow, gruppe.name).toBeCloseTo(grundwert / faktor, 6);
381 geprueft += 1;
382 }
383 expect(geprueft, 'Vorbedingung: es gibt Gruppen mit Verkehrsstärke').toBeGreaterThan(0);
384
385 // Der Altstand rechnete qS = Grundwert / (1 + pSV) - bei 5 % also 1905
386 // statt 1913 Fz/h. Die Zahlen duerfen sich nicht decken, sonst prueft der
387 // Test nichts.
388 expect(schwerverkehrsfaktor(0.05)).not.toBeCloseTo(1.05, 6);
389 });
390 });
391
392 describe('E2 - Wirkungsvermerk der festen Ueberfahrzeiten im Fundstellenverzeichnis', () => {
393 it('vermerkt sie beim Regelansatz als ohne Wirkung', () => {
394 // Befund C20 an einer neuen Stelle: Beim Ansatz "Überfahrzeit = Gelbzeit"
395 // gehen die beiden Zahlen in keinen Plan ein. Ein Verzeichnis, das sie wie
396 // angewandte Werte fuehrt, behauptet eine Wirkung, die sie nicht haben.
397 const text = ausdruck(knotenpunkt());
398 expect(text).toContain(
399 gedruckt(
400 'Überfahrzeit Kfz geradeaus (Ansatz „feste Überfahrzeit") (ohne Wirkung: Überfahrzeit = Gelbzeit)',
401 ),
402 );
403 expect(text).toContain(
404 gedruckt(
405 'Überfahrzeit Kfz abbiegend (Ansatz „feste Überfahrzeit") (ohne Wirkung: Überfahrzeit = Gelbzeit)',
406 ),
407 );
408 });
409
410 it('laesst den Vermerk beim Ansatz "fest" weg und nennt die Quelle mit ihrem Pruefstand', () => {
411 const quelle = QUELLEN['praxis-ueberfahrzeit-fest'];
412 const text = ausdruck(mitFesterUeberfahrzeit());
413
414 expect(text).toContain(gedruckt('Überfahrzeit Kfz geradeaus (Ansatz „feste Überfahrzeit")'));
415 expect(text).not.toContain(gedruckt('(ohne Wirkung: Überfahrzeit = Gelbzeit)'));
416 expect(text).toContain(gedruckt(quelle.sachgebiet));
417 expect(text).toContain(gedruckt(PRUEFSTAND_LABELS[quelle.pruefstand]));
418 // Der tragende Beleg steht gedruckt da - seit 5.27.0 die Fundstelle
419 // statt des Umkehrschlusses aus den Hamburger Zwischenzeitentafeln.
420 expect(text).toContain(gedruckt('Am Original abgeglichen (10.09.2026)'));
421 expect(text).toContain(gedruckt('DER FESTE ANSATZ IST DAMIT DER REGELWERKSANSATZ'));
422 expect(text).not.toContain(gedruckt('Deshalb kein Regelwerkswert.'));
423 });
424 });
425
426 describe('Tabellenausgabe: Ansatz, enger Innenradius und fSV', () => {
427 it('nennt den Schwerverkehrsfaktor je Signalgruppe neben dem Anteil', () => {
428 const projekt = knotenpunkt();
429 const plan = buildSignalPlan(projekt);
430 const csv = signalGroupsCsv(projekt, plan);
431 const kopf = csv.split('\r\n')[0]!;
432
433 expect(kopf).toContain('Schwerverkehrsfaktor fSV');
434 /*
435 * Die Reihenfolge ist die des Rechenwegs: Anteil, Aufteilung, Faktor,
436 * Gleichung. Vor der Fassung 5.6.0 stand der Faktor unmittelbar hinter dem
437 * Anteil; seitdem liegt die Aufteilung dazwischen, weil sie der zweite
438 * Eingangswert der Gleichung ist und nicht ihr Ergebnis.
439 */
440 const spalten = kopf.replace('', '').split(';');
441 const anteil = spalten.indexOf('Schwerverkehrsanteil [%]');
442 expect(spalten.indexOf('Lastzuganteil am Schwerverkehr [%] (leer = nicht erfasst)')).toBe(
443 anteil + 1,
444 );
445 expect(spalten.indexOf('Schwerverkehrsfaktor fSV')).toBe(anteil + 2);
446 expect(spalten.indexOf('Gleichung für fSV (HBS 2015)')).toBe(anteil + 3);
447
448 for (const nachfrage of projekt.demands) {
449 const name = projekt.signalGroups.find((g) => g.id === nachfrage.signalGroupId)!.name;
450 const zeile = csv.split('\r\n').find((z) => z.startsWith(`${name};`))!;
451 // Leere Zelle fuer die Aufteilung: Fuer dieses Projekt ist keine erfasst,
452 // und "0" waere eine Zaehlung, die niemand vorgenommen hat.
453 expect(zeile, name).toContain(
454 `;${fmt.numShort(nachfrage.heavyVehicleShare * 100, 1)};;` +
455 `${fmt.numShort(schwerverkehrsfaktor(nachfrage.heavyVehicleShare), 3)};`,
456 );
457 }
458 });
459
460 it('nennt Ueberfahrzeit-Ansatz und Merkmal je Konfliktbeziehung', () => {
461 const projekt = mitEngemRadius(mitFesterUeberfahrzeit());
462 const plan = buildSignalPlan(projekt);
463 const csv = intergreenDetailCsv(projekt, plan);
464 const zeilen = csv.split('\r\n');
465 const kopf = zeilen[0]!;
466
467 expect(kopf).toContain('Enger Innenradius');
468 // "Herkunft" und nicht mehr "Ansatz" (Fassung 5.5.0, Befund F5): Der
469 // Kfz-Ansatz erreicht Fuss, Rad und OePNV nicht, und die Spalte nennt bei
470 // ihnen deren eigene Herleitung.
471 expect(kopf).toContain('Überfahrzeit-Herkunft');
472
473 // Die gekennzeichnete Beziehung traegt die volle Beschriftung - eine
474 // Tabellenausgabe hat keine Fussnote, in der der Vorbehalt sonst stuende.
475 const erste = projekt.conflicts[0]!;
476 const von = projekt.signalGroups.find((g) => g.id === erste.fromId)!.name;
477 const nach = projekt.signalGroups.find((g) => g.id === erste.toId)!.name;
478 const zeile = zeilen.find((z) => z.startsWith(`${von};${nach};`))!;
479 expect(zeile).toContain(`;${ENGER_RADIUS_LABEL};`);
480 /*
481 * Die Ansatzbeschriftung steht als EIN Feld in der Zeile - mit
482 * Anfuehrungszeichen, sobald sie ein Semikolon enthaelt, sonst ohne.
483 *
484 * NACHGEZOGEN (5.27.0): Hier stand die Anfuehrungszeichen-Fassung fest.
485 * Die Beschriftung des Ansatzes "fest" nennt seit dem Abgleich am Original
486 * die Fundstelle und enthaelt kein Semikolon mehr; die Zusicherung galt
487 * damit einem Zeichen und nicht der Sache. Gemeint war und ist: Die
488 * Beschriftung darf die Zeile nicht in zwei Spalten zerreissen.
489 */
490 const beschriftung = UEBERFAHRZEIT_ANSATZ_LABELS.fest;
491 const feld = beschriftung.includes(';') ? `"${beschriftung}"` : beschriftung;
492 expect(zeile.split(';').length, 'die Beschriftung zerreißt die Zeile').toBe(
493 kopf.split(';').length,
494 );
495 expect(zeile).toContain(feld);
496
497 // Alle uebrigen Beziehungen tragen "nein" - das Merkmal gilt je Beziehung
498 // und nicht fuer den Plan.
499 const weitere = zeilen.filter((z) => z.includes(';nein;'));
500 expect(weitere).toHaveLength(projekt.conflicts.length - 1);
501 });
502 });