import { inflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { buildProjectPdf, DEFAULT_PDF_OPTIONS, type PdfOptions } from '@/services/export/pdf'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { ANLAGENART_GRENZEN, type SchrankenHerkunft } from '@/domain/rilsa/constants'; import { PRUEFSTAND_LABELS, RECHTSGRUNDLAGEN, RECHTSGRUNDLAGEN_EINSTREIFIG, rechtsgrundlagenFuer, } from '@/domain/rilsa/quellen'; import { toWinAnsi } from '@/render/pdfSurface'; import type { Anlagenart, Project } from '@/domain/model/project'; import * as fmt from '@/ui/format'; /* * Unterlagen und Rechtsgrundlagen im Ausdruck - Befunde B8 und C13 bis C16. * * B8: Die Tabelle "Schranken der Anlagenart" schrieb in jede Zeile dieselbe * Quelle der Anlagenart - "RiLSA 2015" auch neben die Wartezeitschwellen, * "RSA 21 - Richtlinien fuer die Sicherung von Arbeitsstellen an Strassen" auch * neben die Umlaufzeit von 30 bis 300 s. Keine dieser Zahlen steht im jeweils * genannten Regelwerk. * C13: Die Rechtsgrundlagen waren fuer jede Anlagenart dieselben; einer * Baustellenampel fehlten RSA 21, TL und ZTV transportable LSA 2023. * C14: Der Ausdruck wies nicht aus, was ihm fehlt - Einsatzzeiten, * Betriebsverantwortlicher und vor allem der bemasste Signallageplan mit den * Signalgeberstandorten, den dieses Programm gar nicht zeichnen kann. * C15: Der Zwischenzeitennachweis war standardmaessig abgewaehlt. * C16: Die Kopfzeile behauptete auf jeder Seite "Planung nach RiLSA 2015" - * auch dort, wo derselbe Ausdruck sagt, die Grenzen stammten NICHT aus der * RiLSA. */ const STICHTAG = new Date('2026-01-01T12:00:00Z'); const ANLAGENARTEN: readonly Anlagenart[] = [ 'knotenpunkt', 'fussgaengerschutzanlage', 'einstreifig', ]; /** Sichtbaren Text aus dem PDF lesen; die Inhaltsstroeme sind komprimiert. */ function pdfText(bytes: Uint8Array): string { const roh = new TextDecoder('latin1').decode(bytes); const teile: string[] = []; const suche = /stream\r?\n/g; let treffer: RegExpExecArray | null; while ((treffer = suche.exec(roh)) !== null) { const start = treffer.index + treffer[0].length; const ende = roh.indexOf('endstream', start); if (ende < 0) continue; let inhalt: string; try { inhalt = inflateSync(Buffer.from(roh.slice(start, ende), 'latin1')).toString('latin1'); } catch { continue; } for (const t of inhalt.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { teile.push(t[1]!.replace(/\\([()\\])/g, '$1')); } } // Der Ausdruck bricht Saetze und Tabellenzellen fuer den Satzspiegel um; fuer // die Suche wieder zusammenziehen. return teile.join(' ').replace(/\s+/g, ' '); } function projekt(anlagenart: Anlagenart): Project { const basis = createStandardIntersectionProject('Hauptstrasse / Bahnhofstrasse', STICHTAG); return { ...basis, anlagenart }; } function ausdruck(project: Project, optionen: PdfOptions = DEFAULT_PDF_OPTIONS): string { const plan = buildSignalPlan(project); const bericht = validateProject(project, plan, STICHTAG); return pdfText(buildProjectPdf(project, plan, bericht, optionen, STICHTAG)); } /** * Die Zeichen des Ausdrucks: Gedankenstrich und deutsche Anfuehrungszeichen * gibt es im Zeichenvorrat der PDF-Standardschriften nicht. Erwartungen laufen * deshalb durch dieselbe Umsetzung wie der Ausdruck selbst. */ const gedruckt = (text: string): string => toWinAnsi(text); describe('B8 - Schranken der Anlagenart: Herkunft und Pruefstand je Zeile', () => { it('druckt zu jeder Schranke die Herkunft des Zahlenwerts samt eigenem Pruefstand', () => { // Gegen den Altstand: Dort stand in allen vier Zeilen dieselbe Quelle der // Anlagenart mit demselben Pruefstand. Erwartet wird hier die Folge // "Angesetzt - Grundlage - Pruefstand" in einer Zeile; sie faellt schon // dann, wenn nur der Pruefstand einer Zeile falsch danebensteht. for (const art of ANLAGENARTEN) { const grenzen = ANLAGENART_GRENZEN[art]; const text = ausdruck(projekt(art)); const zeilen: readonly { angesetzt: string; herkunft: SchrankenHerkunft }[] = [ { angesetzt: `${fmt.seconds(grenzen.cycleTime.min)} bis ${fmt.seconds(grenzen.cycleTime.max)}`, herkunft: grenzen.herkunft.umlaufzeit, }, { angesetzt: `${fmt.seconds(grenzen.cycleTime.recommendedMin)} bis ` + `${fmt.seconds(grenzen.cycleTime.recommendedMax)}`, herkunft: grenzen.herkunft.regelbereich, }, { angesetzt: fmt.meters(grenzen.maxClearingDistance, 0), herkunft: grenzen.herkunft.raeumweg, }, { angesetzt: `ab ${fmt.seconds(grenzen.warteZeitWarnung)} / ab ${fmt.seconds(grenzen.warteZeitFehler)}`, herkunft: grenzen.herkunft.wartezeit, }, ]; for (const zeile of zeilen) { const erwartet = gedruckt( `${zeile.angesetzt} ${zeile.herkunft.text} ${PRUEFSTAND_LABELS[zeile.herkunft.pruefstand]}`, ); expect(text, `${art}: ${erwartet}`).toContain(erwartet); } } }); it('nennt neben den Wartezeitschwellen kein Regelwerk mehr', () => { // Frage E7 (Fassung 5.4.0): Bezifferte Wartezeitschwellen nennt weder die // RiLSA noch die RSA in einer oeffentlich belegten Fassung. Der Altstand // druckte hier am Knotenpunkt "RiLSA 2015 / gesichert", bei einstreifiger // Fuehrung den vollen Namen der RSA. for (const art of ANLAGENARTEN) { const grenzen = ANLAGENART_GRENZEN[art]; const text = ausdruck(projekt(art)); const wartezeit = `ab ${fmt.seconds(grenzen.warteZeitWarnung)} / ab ${fmt.seconds(grenzen.warteZeitFehler)}`; expect(text, art).toContain( gedruckt(`${wartezeit} Schwelle dieses Programms, kein Regelwerkswert`), ); expect(text, art).not.toContain(gedruckt(`${wartezeit} RiLSA 2015 gesichert`)); expect(text, art).not.toContain( gedruckt( `${wartezeit} RSA 21 – Richtlinien für die Sicherung von Arbeitsstellen an Straßen`, ), ); // Der Pruefstand der Wartezeitzeile ist in jeder Anlagenart "abzugleichen". expect(grenzen.herkunft.wartezeit.pruefstand, art).toBe('abzugleichen'); } }); it('weist die Umlaufzeit der Arbeitsstelle als hergeleitet aus, die des Knotenpunkts als RiLSA-Wert', () => { const einstreifig = ausdruck(projekt('einstreifig')); expect(einstreifig).toContain( gedruckt('30 s bis 300 s Schwelle dieses Programms, aus den übrigen Schranken hergeleitet'), ); expect(einstreifig).toContain(gedruckt('kein RSA-Wert')); // Nur die belegte Zeile darf "gesichert" tragen - bei einstreifiger // Fuehrung gibt es keine. expect(einstreifig).toContain(gedruckt('30 s bis 300 s')); expect(einstreifig).not.toContain(gedruckt('30 s bis 300 s RSA 21')); const knoten = ausdruck(projekt('knotenpunkt')); expect(knoten).toContain( gedruckt('30 s bis 120 s RiLSA 2015 – Umlaufzeit am Knotenpunkt (30 bis 120 s) gesichert'), ); /* * NACHGEZOGEN (5.27.0): Der Regelbereich am Knotenpunkt ist belegt und * traegt "gesichert". * * Die Zeile hat zweimal gewechselt. Zuerst stand dort "RiLSA 2015 - * Umlaufzeit (90 s als Obergrenze belegt; 60 s als Untergrenze * abzugleichen)". Die Klaerung der Frage E4 (Fassung 5.4.0) hat den * RiLSA-Bezug der 60 s gestrichen und sie als Zahl dieses Programms * ausgewiesen - richtig, soweit es ohne Druckwerk ging. Die gekaufte * Ausgabe zeigt, was nicht zu sehen war: Die RiLSA nennt 30 s als * Richtwert, und gestrichen ist nur der frueher "normal" genannte * MITTELbereich von 50 bis 75 s. */ const regelbereich = ANLAGENART_GRENZEN.knotenpunkt.herkunft.regelbereich; expect(regelbereich.text).toContain('Abschnitt 2.6'); expect(regelbereich.text).not.toContain('setzt dieses Programm'); expect(regelbereich.pruefstand).toBe('gesichert'); expect(knoten).toContain( gedruckt(`30 s bis 90 s ${regelbereich.text} ${PRUEFSTAND_LABELS[regelbereich.pruefstand]}`), ); }); it('sagt im Einleitungssatz, was die Spalte "Grundlage" bedeutet', () => { const text = ausdruck(projekt('knotenpunkt')); expect(text).toContain(gedruckt('sagt je Zeile, woher der Zahlenwert stammt')); expect(text).toContain(gedruckt('Mehrere dieser Schwellen setzt dieses Programm selbst')); }); }); describe('C13 - Rechtsgrundlagen nach Anlagenart', () => { it('fuehrt bei einstreifiger Fuehrung RSA 21, TL und ZTV transportable LSA 2023 auf', () => { const text = ausdruck(projekt('einstreifig')); expect(rechtsgrundlagenFuer('einstreifig')).toHaveLength( RECHTSGRUNDLAGEN.length + RECHTSGRUNDLAGEN_EINSTREIFIG.length, ); for (const grundlage of rechtsgrundlagenFuer('einstreifig')) { expect(text, grundlage.bezeichnung).toContain(gedruckt(grundlage.bezeichnung)); } // Die Aussagen, auf die es ankommt - und der Pruefstand dazu. expect(text).toContain( gedruckt( 'Signalgeregelte einstreifige Führungen sind nach RiLSA zu berechnen (Teil A 3.2 Abs. 3)', ), ); expect(text).toContain(gedruckt('eingeführt mit ARS Nr. 07/2024')); expect(text).toContain( gedruckt('Typklassen A bis D (Typ D bei kreuzenden Strömen und bei Fußgängerschutzanlagen)'), ); for (const grundlage of RECHTSGRUNDLAGEN_EINSTREIFIG) { expect(grundlage.pruefstand, grundlage.bezeichnung).toBe('abzugleichen'); } }); it('nennt sie an Knotenpunkt und Fussgaengerschutzanlage nicht', () => { // Eine Unterlage, die am Knotenpunkt die RSA 21 als Rechtsgrundlage // auffuehrt, behauptet einen Bezug, den es nicht gibt. for (const art of ['knotenpunkt', 'fussgaengerschutzanlage'] as const) { const text = ausdruck(projekt(art)); expect(rechtsgrundlagenFuer(art)).toEqual(RECHTSGRUNDLAGEN); for (const grundlage of RECHTSGRUNDLAGEN_EINSTREIFIG) { expect(text, `${art}: ${grundlage.bezeichnung}`).not.toContain( gedruckt(grundlage.bezeichnung), ); } expect(text, art).not.toContain(gedruckt('ARS Nr. 07/2024')); // Der Grundstock steht weiterhin da. for (const grundlage of RECHTSGRUNDLAGEN) { expect(text, `${art}: ${grundlage.bezeichnung}`).toContain(gedruckt(grundlage.bezeichnung)); } } }); it('druckt den VwV-StVO-Eintrag mit den Werten, die sie selbst vorgibt (C17)', () => { // Der Eintrag sagte bis Fassung 5.4.0 nur, die VwV-StVO "verweise auf die // Regelwerke der FGSV" - dabei gibt sie Gelb- und Rot-Gelb-Zeit selbst vor, // und genau darauf stuetzt das Programm zwei harte Schranken. const text = ausdruck(projekt('knotenpunkt')); expect(text).toContain(gedruckt('Gibt zu § 37 die Gelbzeiten (3/4/5 s bei 50/60/70 km/h)')); expect(text).toContain(gedruckt('die Rot-Gelb-Zeit (1 s, höchstens 2 s) selbst vor (Rn. 17)')); expect(text).toContain(gedruckt('sieht Lichtsignalanlagen über 70 km/h nicht vor (Rn. 10)')); }); }); describe('C14 - Bestandteile der Anordnungsunterlage', () => { it('weist den bemassten Signallageplan ausdruecklich als NICHT enthalten aus', () => { // Der Kern des Befunds: Das Programm zeichnet Fahrlinien und keine // Signalgeberstandorte. Eine Unterlage, die das verschweigt, sieht // vollstaendig aus. for (const art of ANLAGENARTEN) { const text = ausdruck(projekt(art)); expect(text, art).toContain(gedruckt('Bestandteile der Anordnungsunterlage')); expect(text, art).toContain( gedruckt( 'Bemaßter Signallageplan mit Signalgeberstandorten NICHT Bestandteil dieser Unterlage – gesondert beizubringen', ), ); expect(text, art).toContain( gedruckt('Verkehrszeichenplan NICHT Bestandteil dieser Unterlage – gesondert beizubringen'), ); expect(text, art).toContain( gedruckt( 'wo die Signalgeber stehen, welche Bauform sie haben und wie sie bemaßt sind, geht daraus nicht hervor', ), ); } }); it('nennt fehlende Einsatzzeiten und den fehlenden Betriebsverantwortlichen beim Namen', () => { const text = ausdruck(projekt('einstreifig')); expect(text).toContain(gedruckt('Einsatzzeiten der Anordnung nicht angegeben')); expect(text).toContain( gedruckt('Betriebsverantwortlicher (Name, Firma, Erreichbarkeit) nicht angegeben'), ); }); it('druckt die eingetragenen Angaben statt des Lueckenausweises', () => { const basis = projekt('einstreifig'); const gefuellt: Project = { ...basis, meta: { ...basis.meta, einsatzzeiten: '12.05.-30.06.2027, ganztägig', betriebsverantwortlicher: 'Muster Bau GmbH, Herr Roth, 0170 1234567', }, }; const text = ausdruck(gefuellt); expect(text).toContain(gedruckt('Einsatzzeiten der Anordnung 12.05.-30.06.2027, ganztägig')); expect(text).toContain( gedruckt( 'Betriebsverantwortlicher (Name, Firma, Erreichbarkeit) Muster Bau GmbH, Herr Roth, 0170 1234567', ), ); expect(text).not.toContain(gedruckt('Einsatzzeiten der Anordnung nicht angegeben')); }); it('benennt abgewaehlte Abschnitte als abgewaehlt', () => { const ohne: PdfOptions = { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false, includeReport: false, }; const text = ausdruck(projekt('knotenpunkt'), ohne); expect(text).toContain( gedruckt('Zwischenzeitenmatrix abgewählt – in dieser Ausgabe nicht enthalten'), ); expect(text).toContain(gedruckt('Prüfbericht abgewählt – in dieser Ausgabe nicht enthalten')); // Was enthalten ist, nennt seinen Abschnitt. expect(text).toContain( gedruckt('Phasen und Phasenübergänge ja – Abschnitt „Phasen und Phasenübergänge"'), ); }); it('nennt bei einstreifiger Fuehrung die RSA-Fundstelle, sonst § 45 StVO ohne Regelwerkswert', () => { const einstreifig = ausdruck(projekt('einstreifig')); expect(einstreifig).toContain(gedruckt('Grundlage: RSA 21 Teil A 1.4/1.5')); expect(einstreifig).toContain(gedruckt('ZTV transportable Lichtsignalanlagen 2023')); expect(einstreifig).toContain(gedruckt('der Prüfbericht führt fehlende Angaben als Warnung')); const knoten = ausdruck(projekt('knotenpunkt')); expect(knoten).toContain( gedruckt( 'üblicher Bestandteil einer verkehrsbehördlichen Anordnung nach § 45 StVO; ein bezifferter Regelwerkswert steht dahinter nicht', ), ); expect(knoten).not.toContain(gedruckt('Grundlage: RSA 21 Teil A 1.4/1.5')); }); }); describe('C15 - Rechenweg der Zwischenzeiten und der Wartezeit', () => { it('ist voreingestellt enthalten', () => { expect(DEFAULT_PDF_OPTIONS.includeCalculationDetails).toBe(true); const text = ausdruck(projekt('knotenpunkt')); expect(text).toContain(gedruckt('Zwischenzeiten – Rechenweg')); expect(text).toContain(gedruckt('Leistungsfähigkeit – Rechenweg Wartezeit')); expect(text).not.toContain(gedruckt('ist in dieser Ausgabe abgewählt')); }); it('vermerkt die Abwahl an der Stelle des Abschnitts - genau einmal', () => { const satz = gedruckt( 'Der Rechenweg der Zwischenzeiten und der Wartezeit ist in dieser Ausgabe abgewählt; die ' + 'Werte der Zwischenzeitenmatrix und der Leistungsfähigkeit sind ohne ihn nicht ' + 'nachrechenbar.', ); const text = ausdruck(projekt('knotenpunkt'), { ...DEFAULT_PDF_OPTIONS, includeCalculationDetails: false, }); expect(text).toContain(satz); expect(text.split(satz).length - 1, 'der Satz steht genau einmal').toBe(1); expect(text).not.toContain(gedruckt('Zwischenzeiten – Rechenweg')); // Die Uebersicht der Bestandteile sagt dasselbe mit derselben Folge. expect(text).toContain( gedruckt( 'Rechenweg der Zwischenzeiten und der Wartezeit abgewählt – die Werte der Matrix sind ohne ihn nicht nachrechenbar', ), ); }); it('vermerkt die Abwahl auch dann, wenn die Zwischenzeitenmatrix selbst abgewaehlt ist', () => { // Sonst stuende der Vermerk nirgends: Er haengt an der Stelle des // Rechenwegs, und die erste davon faellt mit der Matrix weg. const text = ausdruck(projekt('knotenpunkt'), { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false, includeCalculationDetails: false, }); expect(text).toContain(gedruckt('ist in dieser Ausgabe abgewählt')); }); /* * Befunde 1, 2 und B-5 (Fassung 5.4.0): Der Rechenweg haengt an zwei * Bedingungen - am eigenen Schalter UND daran, ob der Abschnitt gedruckt * wird, in dem er steht. Geprueft wurde bis dahin nur * `includeCalculationDetails: false` bei sonst voller Ausgabe. */ it('nennt in der Uebersicht nur Abschnitte, die es in dieser Ausgabe gibt', () => { const ohneMatrix = ausdruck(projekt('knotenpunkt'), { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false, }); // Der Rechenweg ist angefordert, aber nur einer der beiden Abschnitte // existiert - die Uebersicht darf nicht "ja - Abschnitte ... und ..." sagen. expect(ohneMatrix).not.toContain( gedruckt( 'ja – Abschnitte „Zwischenzeiten – Rechenweg" und „Leistungsfähigkeit – Rechenweg Wartezeit"', ), ); expect(ohneMatrix).toContain( gedruckt('teilweise – Abschnitt „Leistungsfähigkeit – Rechenweg Wartezeit"'), ); expect(ohneMatrix).toContain( gedruckt( 'der Rechenweg der Zwischenzeiten entfällt mit der abgewählten Zwischenzeitenmatrix', ), ); expect(ohneMatrix).not.toContain(gedruckt('Zwischenzeiten – Rechenweg')); const ohneLeistung = ausdruck(projekt('knotenpunkt'), { ...DEFAULT_PDF_OPTIONS, includePerformance: false, }); expect(ohneLeistung).toContain(gedruckt('teilweise – Abschnitt „Zwischenzeiten – Rechenweg"')); expect(ohneLeistung).not.toContain(gedruckt('Leistungsfähigkeit – Rechenweg Wartezeit')); }); it('sagt es, wenn der angeforderte Rechenweg mit beiden Abschnitten entfaellt', () => { const text = ausdruck(projekt('knotenpunkt'), { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false, includePerformance: false, }); expect(text).toContain( gedruckt( 'entfällt – angefordert, aber beide Abschnitte, in denen er steht (Zwischenzeitenmatrix, Leistungsfähigkeit), sind abgewählt', ), ); expect(text).not.toContain(gedruckt('Zwischenzeiten – Rechenweg')); expect(text).not.toContain(gedruckt('Leistungsfähigkeit – Rechenweg Wartezeit')); }); it('haelt den Abwahlvermerk auch dann, wenn beide Abschnitte abgewaehlt sind', () => { // Der Fall, in dem der Vermerk vor der Fassung 5.4.0 NIRGENDS stand: Er // wurde nur innerhalb der beiden Abschnitte gedruckt. const text = ausdruck(projekt('knotenpunkt'), { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false, includePerformance: false, includeCalculationDetails: false, }); const satz = gedruckt( 'Der Rechenweg der Zwischenzeiten und der Wartezeit ist in dieser Ausgabe abgewählt; die ' + 'Abschnitte, in denen er gestanden hätte - Zwischenzeitenmatrix und Leistungsfähigkeit -, ' + 'sind ebenfalls abgewählt.', ); expect(text).toContain(satz); expect(text.split(satz).length - 1, 'der Satz steht genau einmal').toBe(1); }); it('spricht bei abgewaehlter Matrix nicht von "den Werten der Matrix"', () => { // Befund B-5, zweiter Teil: Der Vermerk behauptete eine Matrix, die die // Unterlage gar nicht enthaelt. const text = ausdruck(projekt('knotenpunkt'), { ...DEFAULT_PDF_OPTIONS, includeIntergreenMatrix: false, includeCalculationDetails: false, }); expect(text).toContain( gedruckt('die Werte der Leistungsfähigkeit sind ohne ihn nicht nachrechenbar'), ); expect(text).not.toContain(gedruckt('die Werte der Matrix sind ohne ihn nicht nachrechenbar.')); }); }); describe('C16 - Kopfzeile nennt die Grundlage der Anlagenart', () => { it('sagt an der Arbeitsstelle RiLSA und RSA, sonst nur RiLSA', () => { const einstreifig = ausdruck(projekt('einstreifig')); expect(einstreifig).toContain( gedruckt('LSA-Planer Professional · Signalzeitenplan nach RiLSA 2015 (RSA 21, Teil A 3.2)'), ); for (const art of ['knotenpunkt', 'fussgaengerschutzanlage'] as const) { const text = ausdruck(projekt(art)); expect(text, art).toContain( gedruckt('LSA-Planer Professional · Signalzeitenplan nach RiLSA 2015'), ); expect(text, art).not.toContain(gedruckt('(RSA 21, Teil A 3.2)')); } }); it('behauptet nirgends mehr pauschal "Planung nach RiLSA 2015"', () => { // Die alte Kopfzeile stand auf JEDER Seite - auch neben dem Satz "Die // Umlaufzeitgrenzen dieses Plans stammen NICHT aus der RiLSA". for (const art of ANLAGENARTEN) { expect(ausdruck(projekt(art)), art).not.toContain(gedruckt('Planung nach RiLSA 2015')); } expect(ausdruck(projekt('einstreifig'))).toContain(gedruckt('stammen NICHT aus der RiLSA')); }); });